]> git.ktnx.net Git - mobile-ledger.git/commitdiff
more pronounced day/month delimiters in the transaction list master
authorDamyan Ivanov <dam+mobileledger@ktnx.net>
Sun, 31 Mar 2024 11:13:26 +0000 (14:13 +0300)
committerDamyan Ivanov <dam+mobileledger@ktnx.net>
Sun, 31 Mar 2024 11:13:26 +0000 (14:13 +0300)
413 files changed:
CHANGES.md
README.md
TODO.txt [new file with mode: 0644]
app/build.gradle
app/schemas/net.ktnx.mobileledger.db.DB/1.json [new file with mode: 0644]
app/schemas/net.ktnx.mobileledger.db.DB/43.json [new file with mode: 0644]
app/schemas/net.ktnx.mobileledger.db.DB/44.json [new file with mode: 0644]
app/schemas/net.ktnx.mobileledger.db.DB/45.json [new file with mode: 0644]
app/schemas/net.ktnx.mobileledger.db.DB/46.json [new file with mode: 0644]
app/schemas/net.ktnx.mobileledger.db.DB/47.json [new file with mode: 0644]
app/schemas/net.ktnx.mobileledger.db.DB/48.json [new file with mode: 0644]
app/schemas/net.ktnx.mobileledger.db.DB/49.json [new file with mode: 0644]
app/schemas/net.ktnx.mobileledger.db.DB/50.json [new file with mode: 0644]
app/schemas/net.ktnx.mobileledger.db.DB/51.json [new file with mode: 0644]
app/schemas/net.ktnx.mobileledger.db.DB/52.json [new file with mode: 0644]
app/schemas/net.ktnx.mobileledger.db.DB/53.json [new file with mode: 0644]
app/schemas/net.ktnx.mobileledger.db.DB/54.json [new file with mode: 0644]
app/schemas/net.ktnx.mobileledger.db.DB/55.json [new file with mode: 0644]
app/schemas/net.ktnx.mobileledger.db.DB/56.json [new file with mode: 0644]
app/schemas/net.ktnx.mobileledger.db.DB/57.json [new file with mode: 0644]
app/schemas/net.ktnx.mobileledger.db.DB/58.json [new file with mode: 0644]
app/schemas/net.ktnx.mobileledger.db.DB/59.json [new file with mode: 0644]
app/schemas/net.ktnx.mobileledger.db.DB/60.json [new file with mode: 0644]
app/schemas/net.ktnx.mobileledger.db.DB/61.json [new file with mode: 0644]
app/schemas/net.ktnx.mobileledger.db.DB/62.json [new file with mode: 0644]
app/schemas/net.ktnx.mobileledger.db.DB/63.json [new file with mode: 0644]
app/schemas/net.ktnx.mobileledger.db.DB/64.json [new file with mode: 0644]
app/schemas/net.ktnx.mobileledger.db.DB/65.json [new file with mode: 0644]
app/schemas/net.ktnx.mobileledger.db.DB/66.json [new file with mode: 0644]
app/src/main/AndroidManifest.xml
app/src/main/java/net/ktnx/mobileledger/App.java
app/src/main/java/net/ktnx/mobileledger/BackupsActivity.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/async/DbOpItem.java [deleted file]
app/src/main/java/net/ktnx/mobileledger/async/DbOpQueue.java [deleted file]
app/src/main/java/net/ktnx/mobileledger/async/DbOpRunner.java [deleted file]
app/src/main/java/net/ktnx/mobileledger/async/DescriptionSelectedCallback.java
app/src/main/java/net/ktnx/mobileledger/async/GeneralBackgroundTasks.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/async/RetrieveTransactionsTask.java
app/src/main/java/net/ktnx/mobileledger/async/SendTransactionTask.java
app/src/main/java/net/ktnx/mobileledger/async/TaskCallback.java
app/src/main/java/net/ktnx/mobileledger/async/TransactionAccumulator.java
app/src/main/java/net/ktnx/mobileledger/async/TransactionDateFinder.java
app/src/main/java/net/ktnx/mobileledger/async/UpdateTransactionsTask.java [deleted file]
app/src/main/java/net/ktnx/mobileledger/backup/ConfigIO.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/backup/ConfigReader.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/backup/ConfigWriter.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/backup/MobileLedgerBackupAgent.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/backup/RawConfigReader.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/backup/RawConfigWriter.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/dao/AccountDAO.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/dao/AccountValueDAO.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/dao/AsyncResultCallback.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/dao/BaseDAO.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/dao/CurrencyDAO.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/dao/OptionDAO.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/dao/ProfileDAO.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/dao/TemplateAccountDAO.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/dao/TemplateHeaderDAO.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/dao/TransactionAccountDAO.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/dao/TransactionDAO.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/db/Account.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/db/AccountAutocompleteAdapter.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/db/AccountValue.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/db/AccountWithAmounts.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/db/AccountWithAmountsAutocompleteAdapter.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/db/Currency.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/db/DB.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/db/Option.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/db/Profile.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/db/TemplateAccount.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/db/TemplateBase.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/db/TemplateHeader.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/db/TemplateWithAccounts.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/db/Transaction.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/db/TransactionAccount.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/db/TransactionDescriptionAutocompleteAdapter.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/db/TransactionWithAccounts.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/err/HTTPException.java
app/src/main/java/net/ktnx/mobileledger/json/API.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/AccountListParser.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/ApiNotSupportedException.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/Gateway.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/ParsedBalance.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/ParsedLedgerAccount.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/ParsedPrice.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/ParsedQuantity.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/ParsedStyle.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/TransactionListParser.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/v1_14/AccountListParser.java
app/src/main/java/net/ktnx/mobileledger/json/v1_14/Gateway.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/v1_14/ParsedBalance.java
app/src/main/java/net/ktnx/mobileledger/json/v1_14/ParsedLedgerAccount.java
app/src/main/java/net/ktnx/mobileledger/json/v1_14/ParsedLedgerTransaction.java
app/src/main/java/net/ktnx/mobileledger/json/v1_14/ParsedStyle.java
app/src/main/java/net/ktnx/mobileledger/json/v1_14/TransactionListParser.java
app/src/main/java/net/ktnx/mobileledger/json/v1_15/AccountListParser.java
app/src/main/java/net/ktnx/mobileledger/json/v1_15/Gateway.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/v1_15/ParsedBalance.java
app/src/main/java/net/ktnx/mobileledger/json/v1_15/ParsedLedgerAccount.java
app/src/main/java/net/ktnx/mobileledger/json/v1_15/ParsedLedgerTransaction.java
app/src/main/java/net/ktnx/mobileledger/json/v1_15/ParsedQuantity.java
app/src/main/java/net/ktnx/mobileledger/json/v1_15/ParsedStyle.java
app/src/main/java/net/ktnx/mobileledger/json/v1_15/TransactionListParser.java
app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/AccountListParser.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/Gateway.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/ParsedAmount.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/ParsedBalance.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/ParsedLedgerAccount.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/ParsedLedgerTransaction.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/ParsedPosting.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/ParsedPrecision.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/ParsedPrice.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/ParsedQuantity.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/ParsedSourcePos.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/ParsedStyle.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/TransactionListParser.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/v1_23/AccountListParser.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/v1_23/Gateway.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/v1_23/ParsedAmount.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/v1_23/ParsedBalance.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/v1_23/ParsedLedgerAccount.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/v1_23/ParsedLedgerTransaction.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/v1_23/ParsedPosting.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/v1_23/ParsedPrice.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/v1_23/ParsedQuantity.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/v1_23/ParsedSourcePos.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/v1_23/ParsedStyle.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/json/v1_23/TransactionListParser.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/model/AccountListItem.java
app/src/main/java/net/ktnx/mobileledger/model/Currency.java
app/src/main/java/net/ktnx/mobileledger/model/Data.java
app/src/main/java/net/ktnx/mobileledger/model/FutureDates.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/model/HledgerVersion.java
app/src/main/java/net/ktnx/mobileledger/model/LedgerAccount.java
app/src/main/java/net/ktnx/mobileledger/model/LedgerAmount.java
app/src/main/java/net/ktnx/mobileledger/model/LedgerTransaction.java
app/src/main/java/net/ktnx/mobileledger/model/LedgerTransactionAccount.java
app/src/main/java/net/ktnx/mobileledger/model/MatchedTemplate.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/model/MobileLedgerProfile.java [deleted file]
app/src/main/java/net/ktnx/mobileledger/model/TemplateDetailSource.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/model/TemplateDetailsItem.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/model/TransactionListItem.java
app/src/main/java/net/ktnx/mobileledger/ui/CurrencySelectorFragment.java
app/src/main/java/net/ktnx/mobileledger/ui/CurrencySelectorModel.java
app/src/main/java/net/ktnx/mobileledger/ui/CurrencySelectorRecyclerViewAdapter.java
app/src/main/java/net/ktnx/mobileledger/ui/DatePickerFragment.java
app/src/main/java/net/ktnx/mobileledger/ui/FabManager.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/ui/HelpDialog.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/ui/MainModel.java
app/src/main/java/net/ktnx/mobileledger/ui/MobileLedgerListFragment.java
app/src/main/java/net/ktnx/mobileledger/ui/OnCurrencyLongClickListener.java
app/src/main/java/net/ktnx/mobileledger/ui/OnCurrencySelectedListener.java
app/src/main/java/net/ktnx/mobileledger/ui/OnSourceSelectedListener.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/ui/QR.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/ui/QRScanCapableFragment.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/ui/TemplateDetailSourceSelectorFragment.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/ui/TemplateDetailSourceSelectorModel.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/ui/TemplateDetailSourceSelectorRecyclerViewAdapter.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/ui/account_summary/AccountSummaryAdapter.java
app/src/main/java/net/ktnx/mobileledger/ui/account_summary/AccountSummaryFragment.java
app/src/main/java/net/ktnx/mobileledger/ui/activity/AsyncCrasher.java [deleted file]
app/src/main/java/net/ktnx/mobileledger/ui/activity/MainActivity.java
app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionActivity.java [deleted file]
app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionFragment.java [deleted file]
app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionItemHolder.java [deleted file]
app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionItemsAdapter.java [deleted file]
app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionModel.java [deleted file]
app/src/main/java/net/ktnx/mobileledger/ui/activity/ProfileDetailActivity.java [deleted file]
app/src/main/java/net/ktnx/mobileledger/ui/activity/ProfileThemedActivity.java
app/src/main/java/net/ktnx/mobileledger/ui/activity/SplashActivity.java
app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionAccountRowItemHolder.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionActivity.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionFragment.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionHeaderItemHolder.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionItemViewHolder.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionItemsAdapter.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionModel.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/ui/profiles/ProfileDetailActivity.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/ui/profiles/ProfileDetailFragment.java
app/src/main/java/net/ktnx/mobileledger/ui/profiles/ProfileDetailModel.java
app/src/main/java/net/ktnx/mobileledger/ui/profiles/ProfilesRecyclerViewAdapter.java
app/src/main/java/net/ktnx/mobileledger/ui/templates/TemplateDetailsAdapter.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/ui/templates/TemplateDetailsFragment.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/ui/templates/TemplateDetailsViewModel.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/ui/templates/TemplateListDivider.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/ui/templates/TemplateListFragment.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/ui/templates/TemplateViewHolder.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/ui/templates/TemplatesActivity.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/ui/templates/TemplatesRecyclerViewAdapter.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/ui/transaction_list/TransactionListAdapter.java
app/src/main/java/net/ktnx/mobileledger/ui/transaction_list/TransactionListDelimiterRowHolder.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/ui/transaction_list/TransactionListFragment.java
app/src/main/java/net/ktnx/mobileledger/ui/transaction_list/TransactionListLastUpdateRowHolder.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/ui/transaction_list/TransactionLoaderStep.java [deleted file]
app/src/main/java/net/ktnx/mobileledger/ui/transaction_list/TransactionRowHolder.java
app/src/main/java/net/ktnx/mobileledger/ui/transaction_list/TransactionRowHolderBase.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/utils/Colors.java
app/src/main/java/net/ktnx/mobileledger/utils/MLDB.java [deleted file]
app/src/main/java/net/ktnx/mobileledger/utils/Misc.java
app/src/main/java/net/ktnx/mobileledger/utils/MobileLedgerDatabase.java [deleted file]
app/src/main/java/net/ktnx/mobileledger/utils/NetworkUtil.java
app/src/main/java/net/ktnx/mobileledger/utils/Profiler.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/utils/SimpleDate.java
app/src/main/res/anim/layout_slide_down.xml [deleted file]
app/src/main/res/anim/rotate_180.xml [deleted file]
app/src/main/res/anim/rotate_180_back.xml [deleted file]
app/src/main/res/anim/slide_down.xml [deleted file]
app/src/main/res/anim/slide_in_right.xml [deleted file]
app/src/main/res/anim/slide_out_right.xml [deleted file]
app/src/main/res/anim/slide_up.xml [deleted file]
app/src/main/res/drawable-anydpi-v26/app_icon.xml
app/src/main/res/drawable-anydpi-v26/app_icon_round.xml
app/src/main/res/drawable-anydpi/app_icon_bg.xml [deleted file]
app/src/main/res/drawable-anydpi/checkbox_star_black.xml [deleted file]
app/src/main/res/drawable-anydpi/checkbox_star_white.xml [deleted file]
app/src/main/res/drawable-anydpi/dashed_border_1dp.xml [deleted file]
app/src/main/res/drawable-anydpi/expand_more_black_24dp.xml [deleted file]
app/src/main/res/drawable-anydpi/ic_baseline_auto_graph_24.xml [new file with mode: 0644]
app/src/main/res/drawable-anydpi/ic_baseline_backup_24.xml [new file with mode: 0644]
app/src/main/res/drawable-anydpi/ic_baseline_drag_handle_24.xml [new file with mode: 0644]
app/src/main/res/drawable-anydpi/ic_baseline_help_24_white.xml [new file with mode: 0644]
app/src/main/res/drawable-anydpi/ic_baseline_help_outline_24_primary.xml [new file with mode: 0644]
app/src/main/res/drawable-anydpi/ic_baseline_import_export_24.xml [new file with mode: 0644]
app/src/main/res/drawable-anydpi/ic_baseline_qr_code_scanner_24.xml [new file with mode: 0644]
app/src/main/res/drawable-anydpi/ic_baseline_restore_24.xml [new file with mode: 0644]
app/src/main/res/drawable-anydpi/ic_cancel_white_24dp.xml [deleted file]
app/src/main/res/drawable-anydpi/ic_check_white_24dp.xml [deleted file]
app/src/main/res/drawable-anydpi/ic_comment_black_24dp.xml [deleted file]
app/src/main/res/drawable-anydpi/ic_event_primary_24dp.xml [deleted file]
app/src/main/res/drawable-anydpi/ic_exit_to_app_black_24dp.xml [deleted file]
app/src/main/res/drawable-anydpi/ic_info_black_24dp.xml [deleted file]
app/src/main/res/drawable-anydpi/ic_keyboard_arrow_down_black_24dp.xml [deleted file]
app/src/main/res/drawable-anydpi/ic_menu_manage.xml [deleted file]
app/src/main/res/drawable-anydpi/ic_menu_send.xml [deleted file]
app/src/main/res/drawable-anydpi/ic_menu_share.xml [deleted file]
app/src/main/res/drawable-anydpi/ic_more_horiz_black_24dp.xml [deleted file]
app/src/main/res/drawable-anydpi/ic_notifications_black_24dp.xml [deleted file]
app/src/main/res/drawable-anydpi/ic_refresh_primary_24dp.xml [deleted file]
app/src/main/res/drawable-anydpi/ic_star_black_24dp.xml [deleted file]
app/src/main/res/drawable-anydpi/ic_star_border_black_24dp.xml [deleted file]
app/src/main/res/drawable-anydpi/ic_star_border_white_24dp.xml [deleted file]
app/src/main/res/drawable-anydpi/ic_star_white_24dp.xml [deleted file]
app/src/main/res/drawable-anydpi/ic_sync_black_24dp.xml [deleted file]
app/src/main/res/drawable-anydpi/ic_thick_check_white.xml [deleted file]
app/src/main/res/drawable-anydpi/ic_unfold_more_black_24dp.xml [deleted file]
app/src/main/res/drawable-anydpi/ic_view_list_black_24dp.xml [deleted file]
app/src/main/res/drawable-anydpi/list_divider_inside_out.xml [deleted file]
app/src/main/res/drawable/ic_baseline_calendar_today_24.xml [deleted file]
app/src/main/res/drawable/ic_launcher_foreground.xml [deleted file]
app/src/main/res/drawable/launcher_foreground.xml [new file with mode: 0644]
app/src/main/res/layout/account_autocomplete_row.xml [new file with mode: 0644]
app/src/main/res/layout/account_list_row.xml [new file with mode: 0644]
app/src/main/res/layout/account_list_summary_row.xml [new file with mode: 0644]
app/src/main/res/layout/account_summary_row.xml [deleted file]
app/src/main/res/layout/activity_main.xml
app/src/main/res/layout/activity_new_transaction.xml
app/src/main/res/layout/activity_profile_detail.xml
app/src/main/res/layout/activity_templates.xml [new file with mode: 0644]
app/src/main/res/layout/fragment_backups.xml [new file with mode: 0644]
app/src/main/res/layout/fragment_currency_selector_list.xml
app/src/main/res/layout/fragment_new_transaction.xml
app/src/main/res/layout/fragment_new_transaction_saving.xml
app/src/main/res/layout/fragment_template_detail_source_selector.xml [new file with mode: 0644]
app/src/main/res/layout/fragment_template_detail_source_selector_list.xml [new file with mode: 0644]
app/src/main/res/layout/fragment_template_list.xml [new file with mode: 0644]
app/src/main/res/layout/main_app_layout.xml [deleted file]
app/src/main/res/layout/main_navigation.xml [deleted file]
app/src/main/res/layout/nav_header_layout.xml
app/src/main/res/layout/nav_profile_list_head.xml [deleted file]
app/src/main/res/layout/new_transaction_account_row.xml [new file with mode: 0644]
app/src/main/res/layout/new_transaction_header_row.xml [new file with mode: 0644]
app/src/main/res/layout/new_transaction_row.xml [deleted file]
app/src/main/res/layout/no_profiles.xml [deleted file]
app/src/main/res/layout/profile_detail.xml
app/src/main/res/layout/profile_list.xml [deleted file]
app/src/main/res/layout/profile_list_content.xml
app/src/main/res/layout/switch_item.xml
app/src/main/res/layout/template_details_account.xml [new file with mode: 0644]
app/src/main/res/layout/template_details_fragment.xml [new file with mode: 0644]
app/src/main/res/layout/template_details_header.xml [new file with mode: 0644]
app/src/main/res/layout/template_list_template_item.xml [new file with mode: 0644]
app/src/main/res/layout/templates_fallback_divider.xml [new file with mode: 0644]
app/src/main/res/layout/transaction_delimiter.xml [new file with mode: 0644]
app/src/main/res/layout/transaction_list_row.xml
app/src/main/res/layout/transaction_list_row_accounts_table_row.xml
app/src/main/res/menu/account_list.xml [new file with mode: 0644]
app/src/main/res/menu/account_summary.xml [deleted file]
app/src/main/res/menu/api_version.xml
app/src/main/res/menu/new_transaction.xml
app/src/main/res/menu/new_transaction_fragment.xml
app/src/main/res/menu/profile_list.xml [deleted file]
app/src/main/res/menu/template_details_menu.xml [new file with mode: 0644]
app/src/main/res/menu/template_list_menu.xml [new file with mode: 0644]
app/src/main/res/navigation/new_transaction_navigation.xml
app/src/main/res/navigation/template_list_navigation.xml [new file with mode: 0644]
app/src/main/res/raw/create_db.sql [deleted file]
app/src/main/res/raw/db_17.sql [new file with mode: 0644]
app/src/main/res/raw/db_18.sql [new file with mode: 0644]
app/src/main/res/raw/db_19.sql [new file with mode: 0644]
app/src/main/res/raw/db_20.sql [new file with mode: 0644]
app/src/main/res/raw/db_20_22.sql [new file with mode: 0644]
app/src/main/res/raw/db_22_30.sql [new file with mode: 0644]
app/src/main/res/raw/db_30_32.sql [new file with mode: 0644]
app/src/main/res/raw/db_32_34.sql [new file with mode: 0644]
app/src/main/res/raw/db_34_40.sql [new file with mode: 0644]
app/src/main/res/raw/db_41.sql [new file with mode: 0644]
app/src/main/res/raw/db_41_58.sql [new file with mode: 0644]
app/src/main/res/raw/db_59.sql [new file with mode: 0644]
app/src/main/res/raw/db_60.sql [new file with mode: 0644]
app/src/main/res/raw/db_61.sql [new file with mode: 0644]
app/src/main/res/raw/db_62.sql [new file with mode: 0644]
app/src/main/res/raw/db_63.sql [new file with mode: 0644]
app/src/main/res/raw/db_64.sql [new file with mode: 0644]
app/src/main/res/raw/sql_0.sql [deleted file]
app/src/main/res/raw/sql_1.sql [deleted file]
app/src/main/res/raw/sql_10.sql [deleted file]
app/src/main/res/raw/sql_11.sql [deleted file]
app/src/main/res/raw/sql_12.sql [deleted file]
app/src/main/res/raw/sql_13.sql [deleted file]
app/src/main/res/raw/sql_14.sql [deleted file]
app/src/main/res/raw/sql_15.sql [deleted file]
app/src/main/res/raw/sql_16.sql [deleted file]
app/src/main/res/raw/sql_17.sql [deleted file]
app/src/main/res/raw/sql_18.sql [deleted file]
app/src/main/res/raw/sql_19.sql [deleted file]
app/src/main/res/raw/sql_2.sql [deleted file]
app/src/main/res/raw/sql_20.sql [deleted file]
app/src/main/res/raw/sql_21.sql [deleted file]
app/src/main/res/raw/sql_22.sql [deleted file]
app/src/main/res/raw/sql_23.sql [deleted file]
app/src/main/res/raw/sql_24.sql [deleted file]
app/src/main/res/raw/sql_25.sql [deleted file]
app/src/main/res/raw/sql_26.sql [deleted file]
app/src/main/res/raw/sql_27.sql [deleted file]
app/src/main/res/raw/sql_28.sql [deleted file]
app/src/main/res/raw/sql_29.sql [deleted file]
app/src/main/res/raw/sql_3.sql [deleted file]
app/src/main/res/raw/sql_30.sql [deleted file]
app/src/main/res/raw/sql_31.sql [deleted file]
app/src/main/res/raw/sql_32.sql [deleted file]
app/src/main/res/raw/sql_33.sql [deleted file]
app/src/main/res/raw/sql_34.sql [deleted file]
app/src/main/res/raw/sql_35.sql [deleted file]
app/src/main/res/raw/sql_36.sql [deleted file]
app/src/main/res/raw/sql_37.sql [deleted file]
app/src/main/res/raw/sql_38.sql [deleted file]
app/src/main/res/raw/sql_39.sql [deleted file]
app/src/main/res/raw/sql_4.sql [deleted file]
app/src/main/res/raw/sql_40.sql [deleted file]
app/src/main/res/raw/sql_41.sql [deleted file]
app/src/main/res/raw/sql_5.sql [deleted file]
app/src/main/res/raw/sql_6.sql [deleted file]
app/src/main/res/raw/sql_7.sql [deleted file]
app/src/main/res/raw/sql_8.sql [deleted file]
app/src/main/res/raw/sql_9.sql [deleted file]
app/src/main/res/values-bg/arrays.xml
app/src/main/res/values-bg/strings.xml
app/src/main/res/values-h360dp/dimens.xml
app/src/main/res/values-night/styles.xml
app/src/main/res/values-v26/styles.xml
app/src/main/res/values/arrays.xml
app/src/main/res/values/dimens.xml
app/src/main/res/values/strings.xml
app/src/main/res/values/styles.xml
app/src/main/res/xml/backup_descriptor.xml [deleted file]
app/src/main/res/xml/network_security_config.xml
app/src/test/java/net/ktnx/mobileledger/model/MobileLedgerProfileTest.java [deleted file]
build.gradle
gradle.properties
gradle/wrapper/gradle-wrapper.properties
gradlew.bat
metadata/bg-BG/changelogs/37.txt [new file with mode: 0644]
metadata/bg-BG/changelogs/38.txt [new file with mode: 0644]
metadata/bg-BG/changelogs/39.txt [new file with mode: 0644]
metadata/bg-BG/changelogs/40.txt [new file with mode: 0644]
metadata/bg-BG/changelogs/41.txt [new file with mode: 0644]
metadata/bg-BG/changelogs/42.txt [new file with mode: 0644]
metadata/bg-BG/changelogs/43.txt [new file with mode: 0644]
metadata/bg-BG/changelogs/44.txt [new file with mode: 0644]
metadata/bg-BG/changelogs/45.txt [new file with mode: 0644]
metadata/bg-BG/changelogs/46.txt [new file with mode: 0644]
metadata/bg-BG/changelogs/47.txt [new file with mode: 0644]
metadata/bg-BG/changelogs/48.txt [new file with mode: 0644]
metadata/bg-BG/changelogs/49.txt [new file with mode: 0644]
metadata/bg-BG/changelogs/50.txt [new file with mode: 0644]
metadata/bg-BG/changelogs/51.txt [new file with mode: 0644]
metadata/bg-BG/changelogs/52.txt [new file with mode: 0644]
metadata/bg-BG/changelogs/53.txt [new file with mode: 0644]
metadata/bg-BG/changelogs/54.txt [new file with mode: 0644]
metadata/bg-BG/changelogs/55.txt [new file with mode: 0644]
metadata/bg-BG/changelogs/56.txt [new file with mode: 0644]
metadata/bg-BG/full_description.txt
metadata/en-US/changelogs/37.txt [new file with mode: 0644]
metadata/en-US/changelogs/38.txt [new file with mode: 0644]
metadata/en-US/changelogs/39.txt [new file with mode: 0644]
metadata/en-US/changelogs/40.txt [new file with mode: 0644]
metadata/en-US/changelogs/41.txt [new file with mode: 0644]
metadata/en-US/changelogs/42.txt [new file with mode: 0644]
metadata/en-US/changelogs/43.txt [new file with mode: 0644]
metadata/en-US/changelogs/44.txt [new file with mode: 0644]
metadata/en-US/changelogs/45.txt [new file with mode: 0644]
metadata/en-US/changelogs/46.txt [new file with mode: 0644]
metadata/en-US/changelogs/47.txt [new file with mode: 0644]
metadata/en-US/changelogs/48.txt [new file with mode: 0644]
metadata/en-US/changelogs/49.txt [new file with mode: 0644]
metadata/en-US/changelogs/50.txt [new file with mode: 0644]
metadata/en-US/changelogs/51.txt [new file with mode: 0644]
metadata/en-US/changelogs/52.txt [new file with mode: 0644]
metadata/en-US/changelogs/53.txt [new file with mode: 0644]
metadata/en-US/changelogs/54.txt [new file with mode: 0644]
metadata/en-US/changelogs/55.txt [new file with mode: 0644]
metadata/en-US/changelogs/56.txt [new file with mode: 0644]
metadata/en-US/full_description.txt

index 5849279cc72163354583d153f30b2a85a916e5cd..02f84284cacf6943424f157b78ec8e114eb5dd6f 100644 (file)
@@ -1,5 +1,161 @@
 # Changes
 
+## [0.21.7] = 2024-03-19
+
+* FIXES:
+    + allow user certificates in network security config
+* OTHERS:
+    + bump gradle version
+
+## [0.21.6] - 2023-06-20
+
+* FIXES
+    + fix sending transations to hledger-web 1.23+
+
+## [0.21.5] - 2022-09-03
+
+* FIXES
+    + fix cloud backup
+
+## [0.21.4] - 2022-06-18
+
+* FIXES
+    + fix compatibility wuth hledger-web 1.23+ when submitting new transactions. Thanks to Faye Duxovni for the patch!
+    + fix a crash when deleting templates
+    + fix a rare crash when submitting transactions with multiple accounts with no amounts with zero remaining balance
+
+## [0.21.3] - 2022-04-06
+
+* FIXES
+    + sync gradle version requirements
+* OTHERS
+    + bump version of several dependent libraries
+    + bump SDK version to 31
+    + adjust deprecated constructor usage
+
+## [0.21.2] - 2022-04-04
+
+* FIXES
+    + fix crash when auto-balancing multi currency transaction
+    + fix crash when duplicating template
+    + fix crash when restoring configuration backup
+* IMPROVEMENTS
+    + new transaction: turn on commodity setting when loading previous transaction with commodities
+
+## [0.21.1] - 2021-12-30
+
+* FIXES
+    + add hledger-web 1.23 support when adding transactions too
+    + correct running total when a matching transaction is added in the past
+    + fix crash when sending transaction containing only empty amounts
+
+## [0.21.0] - 2021-12-09
+
+* NEW
+    + Add support for hledger-web 1.23
+* FIXES
+    + Ship database support file missed in v0.20.4
+
+## [0.20.4] - 2021-11-18
+
+* KNOWN PROBLEMS
+    + Incompatibility with hledger-web 1.23+
+* FIXES
+    + fix auto-completion of transaction description
+
+## [0.20.3] - 2021-09-29
+
+* FIXES
+    + another fix to DB migration from v0.16.0
+
+## [0.20.2] - 2021-09-23
+
+* NEW
+    + cloud backup
+* FIXES
+    + two database problems fixed, one causing crashes at startup
+
+## [0.20.1] - 2021-09-09
+
+* FIXES
+    + New transaction: focus amount upon account selection
+    + New transaction: fix a crash when returning to the activity with no focused input field
+    + fix a crash in DB upgrade introduced in v0.20.0
+    + fix config restore with null values
+    + move away from deprecated AsyncTask
+
+## [0.20.0] - 2021-08-22
+
+* NEW
+    + backup/restore of profile/template configuration to a file
+* FIXES
+    + fix a couple of crashes related to starting new transaction via shortcut
+
+## [0.19.2] - 2020-06-09
+
+* FIXES
+    + fix auto-completion of transaction names with non-ASCII characters on some Android variants/versions (broken in 0.18.0)
+
+## [0.19.1] - 2020-05-23
+
+* FIXES
+    + fix a bug in new transaction screen when an invalid amount is entered
+    + fix loading a previous transaction by description (again)
+    + fix crash when parsing of hledger version with only two components
+
+## [0.19.0] - 2020-05-10
+
+* NEW
+    + add commodity support to the templates
+    + display running totals when filtering transaction list by account
+    + show current balance in account chooser (new transactions)
+* IMPROVEMENTS
+    + more prominent background for auto-complete pop-ups in dark mode
+    + better placement of account balances with very long/deep account names
+* FIXES
+    + honor default commodity setting in new transaction screen
+    + honor changes in currently active profile
+    + fix propagation of speculative account updates to parent accounts
+
+## [0.18.0] - 2020-05-05
+
+* NEW
+    + newly added transactions are visible in transaction list without a refresh
+* IMPROVEMENTS
+    + finished migration to fully asynchronous database layer
+    + better responsiveness when switching from the account list to the transaction list for the first time
+* FIXES
+    + fix layout glitches in template editor
+    + fix error handling while trying different JSON API versions
+    + stop resetting the date when an old transaction is loaded
+    + several smaller fixes
+
+## [0.17.1] - 2020-03-24
+
+* FIXES
+    + fix a bug in db migration for profiles without detected version
+
+## [0.17.0] - 2020-03-11
+
+* NEW
+    + transaction templates, applied via QR scan
+* IMPROVEMENTS
+    + bigger commodify button in new transaction screen
+    + unified floating action button behaviour
+    + start migration to a fully asynchronous database layer
+
+## [0.16.0] - 2020-12-28
+
+* NEW
+    + add support for latest JSON API (hledger-web 1.19.1)
+    + backend server version detection
+    + backend communication supports multiple JSON API versions
+* IMPROVEMENTS
+    + do database-related initialization in the background while the splash screen is shown
+* FIXES
+    + honour default currency in new transaction entry
+    + several crashes fixed
+
 ## [0.15.0] - 2020-09-20
 
 * NEW
index b6b2ac1753b4854be14a7329779cdf3ac39bd82b..a9db50f35f6e0771bc62a8388cf1a80513945390 100644 (file)
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@ MoLe is a front-end to [[https://hledger.org/hledger-web.html hledger-web]]
 
 ## Main software
 
-Copyright ⓒ 2018, 2019, 2020 Damyan Ivanov <dam+mole@ktnx.net>
+Copyright ⓒ 2018, 2019, 2020, 2021 Damyan Ivanov <dam+mole@ktnx.net>
 
 MoLe is free software: you can distribute it and/or modify it
 under the term of the GNU General Public License as published by
@@ -17,7 +17,8 @@ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 GNU General Public License terms for details.
 
 You should have received a copy of the GNU General Public License
-along with Mobile-Ledger. If not, see <https://www.gnu.org/licenses/>.
+along with Mobile-Ledger in a file named COPYING.txt. If not, see
+<https://www.gnu.org/licenses/>.
 
 ## Libraries
 
@@ -34,10 +35,12 @@ Apache (Software) License, version 2.0 ("the License").
 See the License for details about distribution rights, and the
 specific rights regarding derivate works.
 
-You may obtain a copy of the License at: http://www.apache.org/licenses/LICENSE-2.0
+Apache license is in Apache-2.0.txt. You may obtain a copy at:
+http://www.apache.org/licenses/LICENSE-2.0
 
 ## Other items
 
-Some icons taken from the Android open-source project are 
-Copyright Google Inc and/or Android open-source project and licensed under the
-Apache License, version 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
+Some icons taken from the Android open-source project are Copyright Google Inc
+and/or Android open-source project and licensed under the Apache License,
+version 2.0 (See Apache-2.0.txt or
+<https://www.apache.org/licenses/LICENSE-2.0>)
diff --git a/TODO.txt b/TODO.txt
new file mode 100644 (file)
index 0000000..e229e17
--- /dev/null
+++ b/TODO.txt
@@ -0,0 +1,7 @@
+* Easy way to add tag:value pairs to transactions and transaction accounts
+
+* Filter by tag:value pairs
+
+* Refresh button in account/transaction list
+
+* Top button in account/transaction list
index a485804ffe2287f77c49eaa809d8456f2e6785b5..71673f6eb8dca03508bc0f12f40580f9893fda37 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2020 Damyan Ivanov.
+ * Copyright © 2023 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
 apply plugin: 'com.android.application'
 
 android {
-    compileSdkVersion 29
+    compileSdkVersion 31
     defaultConfig {
         applicationId "net.ktnx.mobileledger"
         minSdkVersion 22
-        targetSdkVersion 29
+        targetSdkVersion 31
         vectorDrawables.useSupportLibrary true
-        versionCode 36
-        versionName '0.15.0'
+        versionCode 56
+        versionName '0.21.7'
         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+        javaCompileOptions {
+            annotationProcessorOptions {
+                arguments += [
+                        "room.schemaLocation"  : "$projectDir/schemas".toString(),
+                        "room.incremental"     : "true",
+                        "room.expandProjection": "true"
+                ]
+            }
+        }
     }
     buildTypes {
         release {
@@ -37,9 +46,14 @@ android {
             versionNameSuffix '-debug'
             applicationIdSuffix '.debug'
         }
+        pre {
+            applicationIdSuffix '.pre'
+            versionNameSuffix '-pre'
+            minifyEnabled false
+            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+        }
     }
     sourceSets { main { assets.srcDirs = ['src/main/assets', 'src/main/assets/'] } }
-    buildToolsVersion '29.0.3'
     compileOptions {
         sourceCompatibility JavaVersion.VERSION_1_8
         targetCompatibility JavaVersion.VERSION_1_8
@@ -47,24 +61,31 @@ android {
     productFlavors {
     }
     buildFeatures.viewBinding = true
+    buildToolsVersion '30.0.3'
+    namespace 'net.ktnx.mobileledger'
 }
 
 dependencies {
-    def nav_version = '2.3.0'
+    implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.1'
+    implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1'
+    def room_version = '2.4.2'
+    implementation "androidx.room:room-runtime:2.4.2"
+    annotationProcessor "androidx.room:room-compiler:$room_version"
+    def nav_version = '2.4.2'
     implementation fileTree(include: ['*.jar'], dir: 'libs')
     implementation 'androidx.legacy:legacy-support-v4:1.0.0'
-    implementation 'com.google.android.material:material:1.3.0-alpha01'
-    implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
+    implementation 'com.google.android.material:material:1.5.0'
+    implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
     implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
-    implementation 'androidx.recyclerview:recyclerview:1.1.0'
-    testImplementation 'junit:junit:4.13'
-    androidTestImplementation 'androidx.test:runner:1.3.0-rc01'
-    androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0-rc01'
-    implementation 'org.jetbrains:annotations:15.0'
-    implementation 'com.fasterxml.jackson.module:jackson-modules-java8:2.11.0'
+    implementation 'androidx.recyclerview:recyclerview:1.2.1'
+    testImplementation 'junit:junit:4.13.2'
+    androidTestImplementation 'androidx.test:runner:1.5.0-alpha02'
+    androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0-alpha05'
+    implementation 'org.jetbrains:annotations:23.0.0'
+    implementation 'com.fasterxml.jackson.module:jackson-modules-java8:2.13.2'
     implementation "androidx.navigation:navigation-fragment:$nav_version"
     implementation "androidx.navigation:navigation-ui:$nav_version"
-    implementation 'androidx.appcompat:appcompat:1.3.0-alpha01'
+    implementation 'androidx.appcompat:appcompat:1.6.0-alpha01'
 }
 
 allprojects {
diff --git a/app/schemas/net.ktnx.mobileledger.db.DB/1.json b/app/schemas/net.ktnx.mobileledger.db.DB/1.json
new file mode 100644 (file)
index 0000000..5913a85
--- /dev/null
@@ -0,0 +1,192 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 1,
+    "identityHash": "6326a6bda275905c5eed9228a22612a6",
+    "entities": [
+      {
+        "tableName": "patterns",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT NOT NULL, `position` INTEGER, `regular_expression` TEXT NOT NULL, `transaction_description` TEXT, `transaction_description_match_group` INTEGER, `transaction_comment` TEXT, `transaction_comment_match_group` INTEGER, `date_year` INTEGER, `date_year_match_group` INTEGER, `date_month` INTEGER, `date_month_match_group` INTEGER, `date_day` INTEGER, `date_day_match_group` INTEGER)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "regularExpression",
+            "columnName": "regular_expression",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "transactionDescription",
+            "columnName": "transaction_description",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescriptionMatchGroup",
+            "columnName": "transaction_description_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionComment",
+            "columnName": "transaction_comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionCommentMatchGroup",
+            "columnName": "transaction_comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYear",
+            "columnName": "date_year",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYearMatchGroup",
+            "columnName": "date_year_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonth",
+            "columnName": "date_month",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonthMatchGroup",
+            "columnName": "date_month_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDay",
+            "columnName": "date_day",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDayMatchGroup",
+            "columnName": "date_day_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "pattern_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `pattern_id` INTEGER, `acc` TEXT, `position` INTEGER, `acc_match_group` INTEGER, `currency` INTEGER, `currency_match_group` INTEGER, `amount` REAL, `amount_match_group` INTEGER, `comment` TEXT, `comment_match_group` INTEGER)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "patternId",
+            "columnName": "pattern_id",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "acc",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountNameMatchGroup",
+            "columnName": "acc_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currencyMatchGroup",
+            "columnName": "currency_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amountMatchGroup",
+            "columnName": "amount_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountComment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountCommentMatchGroup",
+            "columnName": "comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "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, '6326a6bda275905c5eed9228a22612a6')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/app/schemas/net.ktnx.mobileledger.db.DB/43.json b/app/schemas/net.ktnx.mobileledger.db.DB/43.json
new file mode 100644 (file)
index 0000000..d98ded0
--- /dev/null
@@ -0,0 +1,192 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 43,
+    "identityHash": "6326a6bda275905c5eed9228a22612a6",
+    "entities": [
+      {
+        "tableName": "patterns",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT NOT NULL, `position` INTEGER, `regular_expression` TEXT NOT NULL, `transaction_description` TEXT, `transaction_description_match_group` INTEGER, `transaction_comment` TEXT, `transaction_comment_match_group` INTEGER, `date_year` INTEGER, `date_year_match_group` INTEGER, `date_month` INTEGER, `date_month_match_group` INTEGER, `date_day` INTEGER, `date_day_match_group` INTEGER)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "regularExpression",
+            "columnName": "regular_expression",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "transactionDescription",
+            "columnName": "transaction_description",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescriptionMatchGroup",
+            "columnName": "transaction_description_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionComment",
+            "columnName": "transaction_comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionCommentMatchGroup",
+            "columnName": "transaction_comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYear",
+            "columnName": "date_year",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYearMatchGroup",
+            "columnName": "date_year_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonth",
+            "columnName": "date_month",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonthMatchGroup",
+            "columnName": "date_month_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDay",
+            "columnName": "date_day",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDayMatchGroup",
+            "columnName": "date_day_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "pattern_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `pattern_id` INTEGER, `acc` TEXT, `position` INTEGER, `acc_match_group` INTEGER, `currency` INTEGER, `currency_match_group` INTEGER, `amount` REAL, `amount_match_group` INTEGER, `comment` TEXT, `comment_match_group` INTEGER)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "patternId",
+            "columnName": "pattern_id",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "acc",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountNameMatchGroup",
+            "columnName": "acc_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currencyMatchGroup",
+            "columnName": "currency_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amountMatchGroup",
+            "columnName": "amount_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountComment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountCommentMatchGroup",
+            "columnName": "comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "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, '6326a6bda275905c5eed9228a22612a6')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/app/schemas/net.ktnx.mobileledger.db.DB/44.json b/app/schemas/net.ktnx.mobileledger.db.DB/44.json
new file mode 100644 (file)
index 0000000..8b602ce
--- /dev/null
@@ -0,0 +1,192 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 44,
+    "identityHash": "6326a6bda275905c5eed9228a22612a6",
+    "entities": [
+      {
+        "tableName": "patterns",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT NOT NULL, `position` INTEGER, `regular_expression` TEXT NOT NULL, `transaction_description` TEXT, `transaction_description_match_group` INTEGER, `transaction_comment` TEXT, `transaction_comment_match_group` INTEGER, `date_year` INTEGER, `date_year_match_group` INTEGER, `date_month` INTEGER, `date_month_match_group` INTEGER, `date_day` INTEGER, `date_day_match_group` INTEGER)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "regularExpression",
+            "columnName": "regular_expression",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "transactionDescription",
+            "columnName": "transaction_description",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescriptionMatchGroup",
+            "columnName": "transaction_description_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionComment",
+            "columnName": "transaction_comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionCommentMatchGroup",
+            "columnName": "transaction_comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYear",
+            "columnName": "date_year",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYearMatchGroup",
+            "columnName": "date_year_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonth",
+            "columnName": "date_month",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonthMatchGroup",
+            "columnName": "date_month_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDay",
+            "columnName": "date_day",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDayMatchGroup",
+            "columnName": "date_day_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "pattern_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `pattern_id` INTEGER, `acc` TEXT, `position` INTEGER, `acc_match_group` INTEGER, `currency` INTEGER, `currency_match_group` INTEGER, `amount` REAL, `amount_match_group` INTEGER, `comment` TEXT, `comment_match_group` INTEGER)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "patternId",
+            "columnName": "pattern_id",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "acc",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountNameMatchGroup",
+            "columnName": "acc_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currencyMatchGroup",
+            "columnName": "currency_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amountMatchGroup",
+            "columnName": "amount_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountComment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountCommentMatchGroup",
+            "columnName": "comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "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, '6326a6bda275905c5eed9228a22612a6')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/app/schemas/net.ktnx.mobileledger.db.DB/45.json b/app/schemas/net.ktnx.mobileledger.db.DB/45.json
new file mode 100644 (file)
index 0000000..0b47e0b
--- /dev/null
@@ -0,0 +1,192 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 45,
+    "identityHash": "52e5cab6607fcee6f0cd8d39ba38415a",
+    "entities": [
+      {
+        "tableName": "patterns",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `position` INTEGER NOT NULL, `regular_expression` TEXT NOT NULL, `transaction_description` TEXT, `transaction_description_match_group` INTEGER, `transaction_comment` TEXT, `transaction_comment_match_group` INTEGER, `date_year` INTEGER, `date_year_match_group` INTEGER, `date_month` INTEGER, `date_month_match_group` INTEGER, `date_day` INTEGER, `date_day_match_group` INTEGER)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "regularExpression",
+            "columnName": "regular_expression",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "transactionDescription",
+            "columnName": "transaction_description",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescriptionMatchGroup",
+            "columnName": "transaction_description_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionComment",
+            "columnName": "transaction_comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionCommentMatchGroup",
+            "columnName": "transaction_comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYear",
+            "columnName": "date_year",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYearMatchGroup",
+            "columnName": "date_year_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonth",
+            "columnName": "date_month",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonthMatchGroup",
+            "columnName": "date_month_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDay",
+            "columnName": "date_day",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDayMatchGroup",
+            "columnName": "date_day_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "pattern_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `pattern_id` INTEGER, `acc` TEXT, `position` INTEGER, `acc_match_group` INTEGER, `currency` INTEGER, `currency_match_group` INTEGER, `amount` REAL, `amount_match_group` INTEGER, `comment` TEXT, `comment_match_group` INTEGER)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "patternId",
+            "columnName": "pattern_id",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "acc",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountNameMatchGroup",
+            "columnName": "acc_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currencyMatchGroup",
+            "columnName": "currency_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amountMatchGroup",
+            "columnName": "amount_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountComment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountCommentMatchGroup",
+            "columnName": "comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "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, '52e5cab6607fcee6f0cd8d39ba38415a')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/app/schemas/net.ktnx.mobileledger.db.DB/46.json b/app/schemas/net.ktnx.mobileledger.db.DB/46.json
new file mode 100644 (file)
index 0000000..e4b78ce
--- /dev/null
@@ -0,0 +1,192 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 46,
+    "identityHash": "52e5cab6607fcee6f0cd8d39ba38415a",
+    "entities": [
+      {
+        "tableName": "patterns",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `position` INTEGER NOT NULL, `regular_expression` TEXT NOT NULL, `transaction_description` TEXT, `transaction_description_match_group` INTEGER, `transaction_comment` TEXT, `transaction_comment_match_group` INTEGER, `date_year` INTEGER, `date_year_match_group` INTEGER, `date_month` INTEGER, `date_month_match_group` INTEGER, `date_day` INTEGER, `date_day_match_group` INTEGER)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "regularExpression",
+            "columnName": "regular_expression",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "transactionDescription",
+            "columnName": "transaction_description",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescriptionMatchGroup",
+            "columnName": "transaction_description_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionComment",
+            "columnName": "transaction_comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionCommentMatchGroup",
+            "columnName": "transaction_comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYear",
+            "columnName": "date_year",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYearMatchGroup",
+            "columnName": "date_year_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonth",
+            "columnName": "date_month",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonthMatchGroup",
+            "columnName": "date_month_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDay",
+            "columnName": "date_day",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDayMatchGroup",
+            "columnName": "date_day_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "pattern_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `pattern_id` INTEGER, `acc` TEXT, `position` INTEGER, `acc_match_group` INTEGER, `currency` INTEGER, `currency_match_group` INTEGER, `amount` REAL, `amount_match_group` INTEGER, `comment` TEXT, `comment_match_group` INTEGER)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "patternId",
+            "columnName": "pattern_id",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "acc",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountNameMatchGroup",
+            "columnName": "acc_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currencyMatchGroup",
+            "columnName": "currency_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amountMatchGroup",
+            "columnName": "amount_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountComment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountCommentMatchGroup",
+            "columnName": "comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "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, '52e5cab6607fcee6f0cd8d39ba38415a')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/app/schemas/net.ktnx.mobileledger.db.DB/47.json b/app/schemas/net.ktnx.mobileledger.db.DB/47.json
new file mode 100644 (file)
index 0000000..96a61f7
--- /dev/null
@@ -0,0 +1,271 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 47,
+    "identityHash": "ac17a190c6cd158bcb3eae9471522c6a",
+    "entities": [
+      {
+        "tableName": "patterns",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `position` INTEGER NOT NULL, `regular_expression` TEXT NOT NULL, `transaction_description` TEXT, `transaction_description_match_group` INTEGER, `transaction_comment` TEXT, `transaction_comment_match_group` INTEGER, `date_year` INTEGER, `date_year_match_group` INTEGER, `date_month` INTEGER, `date_month_match_group` INTEGER, `date_day` INTEGER, `date_day_match_group` INTEGER)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "regularExpression",
+            "columnName": "regular_expression",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "transactionDescription",
+            "columnName": "transaction_description",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescriptionMatchGroup",
+            "columnName": "transaction_description_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionComment",
+            "columnName": "transaction_comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionCommentMatchGroup",
+            "columnName": "transaction_comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYear",
+            "columnName": "date_year",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYearMatchGroup",
+            "columnName": "date_year_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonth",
+            "columnName": "date_month",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonthMatchGroup",
+            "columnName": "date_month_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDay",
+            "columnName": "date_day",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDayMatchGroup",
+            "columnName": "date_day_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_patterns_id",
+            "unique": true,
+            "columnNames": [
+              "id"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_patterns_id` ON `${TABLE_NAME}` (`id`)"
+          }
+        ],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "pattern_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pattern_id` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `acc` TEXT, `position` INTEGER NOT NULL, `acc_match_group` INTEGER, `currency` INTEGER, `currency_match_group` INTEGER, `amount` REAL, `amount_match_group` INTEGER, `comment` TEXT, `comment_match_group` INTEGER, FOREIGN KEY(`pattern_id`) REFERENCES `patterns`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`currency`) REFERENCES `currencies`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
+        "fields": [
+          {
+            "fieldPath": "patternId",
+            "columnName": "pattern_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "acc",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountNameMatchGroup",
+            "columnName": "acc_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currencyMatchGroup",
+            "columnName": "currency_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amountMatchGroup",
+            "columnName": "amount_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountComment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountCommentMatchGroup",
+            "columnName": "comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_pattern_accounts",
+            "unique": true,
+            "columnNames": [
+              "id"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_pattern_accounts` ON `${TABLE_NAME}` (`id`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "patterns",
+            "onDelete": "NO ACTION",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "pattern_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          },
+          {
+            "table": "currencies",
+            "onDelete": "NO ACTION",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "currency"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "currencies",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `position` TEXT NOT NULL, `has_gap` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "hasGap",
+            "columnName": "has_gap",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "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, 'ac17a190c6cd158bcb3eae9471522c6a')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/app/schemas/net.ktnx.mobileledger.db.DB/48.json b/app/schemas/net.ktnx.mobileledger.db.DB/48.json
new file mode 100644 (file)
index 0000000..72939fa
--- /dev/null
@@ -0,0 +1,271 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 48,
+    "identityHash": "ac17a190c6cd158bcb3eae9471522c6a",
+    "entities": [
+      {
+        "tableName": "patterns",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `position` INTEGER NOT NULL, `regular_expression` TEXT NOT NULL, `transaction_description` TEXT, `transaction_description_match_group` INTEGER, `transaction_comment` TEXT, `transaction_comment_match_group` INTEGER, `date_year` INTEGER, `date_year_match_group` INTEGER, `date_month` INTEGER, `date_month_match_group` INTEGER, `date_day` INTEGER, `date_day_match_group` INTEGER)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "regularExpression",
+            "columnName": "regular_expression",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "transactionDescription",
+            "columnName": "transaction_description",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescriptionMatchGroup",
+            "columnName": "transaction_description_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionComment",
+            "columnName": "transaction_comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionCommentMatchGroup",
+            "columnName": "transaction_comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYear",
+            "columnName": "date_year",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYearMatchGroup",
+            "columnName": "date_year_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonth",
+            "columnName": "date_month",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonthMatchGroup",
+            "columnName": "date_month_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDay",
+            "columnName": "date_day",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDayMatchGroup",
+            "columnName": "date_day_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_patterns_id",
+            "unique": true,
+            "columnNames": [
+              "id"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_patterns_id` ON `${TABLE_NAME}` (`id`)"
+          }
+        ],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "pattern_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pattern_id` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `acc` TEXT, `position` INTEGER NOT NULL, `acc_match_group` INTEGER, `currency` INTEGER, `currency_match_group` INTEGER, `amount` REAL, `amount_match_group` INTEGER, `comment` TEXT, `comment_match_group` INTEGER, FOREIGN KEY(`pattern_id`) REFERENCES `patterns`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`currency`) REFERENCES `currencies`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
+        "fields": [
+          {
+            "fieldPath": "patternId",
+            "columnName": "pattern_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "acc",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountNameMatchGroup",
+            "columnName": "acc_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currencyMatchGroup",
+            "columnName": "currency_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amountMatchGroup",
+            "columnName": "amount_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountComment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountCommentMatchGroup",
+            "columnName": "comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_pattern_accounts",
+            "unique": true,
+            "columnNames": [
+              "id"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_pattern_accounts` ON `${TABLE_NAME}` (`id`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "patterns",
+            "onDelete": "NO ACTION",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "pattern_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          },
+          {
+            "table": "currencies",
+            "onDelete": "NO ACTION",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "currency"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "currencies",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `position` TEXT NOT NULL, `has_gap` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "hasGap",
+            "columnName": "has_gap",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "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, 'ac17a190c6cd158bcb3eae9471522c6a')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/app/schemas/net.ktnx.mobileledger.db.DB/49.json b/app/schemas/net.ktnx.mobileledger.db.DB/49.json
new file mode 100644 (file)
index 0000000..09dc625
--- /dev/null
@@ -0,0 +1,271 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 49,
+    "identityHash": "ac17a190c6cd158bcb3eae9471522c6a",
+    "entities": [
+      {
+        "tableName": "patterns",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `position` INTEGER NOT NULL, `regular_expression` TEXT NOT NULL, `transaction_description` TEXT, `transaction_description_match_group` INTEGER, `transaction_comment` TEXT, `transaction_comment_match_group` INTEGER, `date_year` INTEGER, `date_year_match_group` INTEGER, `date_month` INTEGER, `date_month_match_group` INTEGER, `date_day` INTEGER, `date_day_match_group` INTEGER)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "regularExpression",
+            "columnName": "regular_expression",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "transactionDescription",
+            "columnName": "transaction_description",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescriptionMatchGroup",
+            "columnName": "transaction_description_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionComment",
+            "columnName": "transaction_comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionCommentMatchGroup",
+            "columnName": "transaction_comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYear",
+            "columnName": "date_year",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYearMatchGroup",
+            "columnName": "date_year_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonth",
+            "columnName": "date_month",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonthMatchGroup",
+            "columnName": "date_month_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDay",
+            "columnName": "date_day",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDayMatchGroup",
+            "columnName": "date_day_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_patterns_id",
+            "unique": true,
+            "columnNames": [
+              "id"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_patterns_id` ON `${TABLE_NAME}` (`id`)"
+          }
+        ],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "pattern_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pattern_id` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `acc` TEXT, `position` INTEGER NOT NULL, `acc_match_group` INTEGER, `currency` INTEGER, `currency_match_group` INTEGER, `amount` REAL, `amount_match_group` INTEGER, `comment` TEXT, `comment_match_group` INTEGER, FOREIGN KEY(`pattern_id`) REFERENCES `patterns`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`currency`) REFERENCES `currencies`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
+        "fields": [
+          {
+            "fieldPath": "patternId",
+            "columnName": "pattern_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "acc",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountNameMatchGroup",
+            "columnName": "acc_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currencyMatchGroup",
+            "columnName": "currency_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amountMatchGroup",
+            "columnName": "amount_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountComment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountCommentMatchGroup",
+            "columnName": "comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_pattern_accounts",
+            "unique": true,
+            "columnNames": [
+              "id"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_pattern_accounts` ON `${TABLE_NAME}` (`id`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "patterns",
+            "onDelete": "NO ACTION",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "pattern_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          },
+          {
+            "table": "currencies",
+            "onDelete": "NO ACTION",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "currency"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "currencies",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `position` TEXT NOT NULL, `has_gap` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "hasGap",
+            "columnName": "has_gap",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "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, 'ac17a190c6cd158bcb3eae9471522c6a')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/app/schemas/net.ktnx.mobileledger.db.DB/50.json b/app/schemas/net.ktnx.mobileledger.db.DB/50.json
new file mode 100644 (file)
index 0000000..c8f081e
--- /dev/null
@@ -0,0 +1,277 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 50,
+    "identityHash": "c149635e66c6c7a1e88973463ab4f35d",
+    "entities": [
+      {
+        "tableName": "patterns",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `position` INTEGER NOT NULL, `regular_expression` TEXT NOT NULL, `test_text` TEXT, `transaction_description` TEXT, `transaction_description_match_group` INTEGER, `transaction_comment` TEXT, `transaction_comment_match_group` INTEGER, `date_year` INTEGER, `date_year_match_group` INTEGER, `date_month` INTEGER, `date_month_match_group` INTEGER, `date_day` INTEGER, `date_day_match_group` INTEGER)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "regularExpression",
+            "columnName": "regular_expression",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "testText",
+            "columnName": "test_text",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescription",
+            "columnName": "transaction_description",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescriptionMatchGroup",
+            "columnName": "transaction_description_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionComment",
+            "columnName": "transaction_comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionCommentMatchGroup",
+            "columnName": "transaction_comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYear",
+            "columnName": "date_year",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYearMatchGroup",
+            "columnName": "date_year_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonth",
+            "columnName": "date_month",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonthMatchGroup",
+            "columnName": "date_month_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDay",
+            "columnName": "date_day",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDayMatchGroup",
+            "columnName": "date_day_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_patterns_id",
+            "unique": true,
+            "columnNames": [
+              "id"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_patterns_id` ON `${TABLE_NAME}` (`id`)"
+          }
+        ],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "pattern_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pattern_id` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `acc` TEXT, `position` INTEGER NOT NULL, `acc_match_group` INTEGER, `currency` INTEGER, `currency_match_group` INTEGER, `amount` REAL, `amount_match_group` INTEGER, `comment` TEXT, `comment_match_group` INTEGER, FOREIGN KEY(`pattern_id`) REFERENCES `patterns`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`currency`) REFERENCES `currencies`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
+        "fields": [
+          {
+            "fieldPath": "patternId",
+            "columnName": "pattern_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "acc",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountNameMatchGroup",
+            "columnName": "acc_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currencyMatchGroup",
+            "columnName": "currency_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amountMatchGroup",
+            "columnName": "amount_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountComment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountCommentMatchGroup",
+            "columnName": "comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_pattern_accounts",
+            "unique": true,
+            "columnNames": [
+              "id"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_pattern_accounts` ON `${TABLE_NAME}` (`id`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "patterns",
+            "onDelete": "NO ACTION",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "pattern_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          },
+          {
+            "table": "currencies",
+            "onDelete": "NO ACTION",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "currency"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "currencies",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `position` TEXT NOT NULL, `has_gap` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "hasGap",
+            "columnName": "has_gap",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "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, 'c149635e66c6c7a1e88973463ab4f35d')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/app/schemas/net.ktnx.mobileledger.db.DB/51.json b/app/schemas/net.ktnx.mobileledger.db.DB/51.json
new file mode 100644 (file)
index 0000000..f755a4b
--- /dev/null
@@ -0,0 +1,287 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 51,
+    "identityHash": "d80c63258c511ee305dc5f0062a8af2a",
+    "entities": [
+      {
+        "tableName": "patterns",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `regular_expression` TEXT NOT NULL, `test_text` TEXT, `transaction_description` TEXT, `transaction_description_match_group` INTEGER, `transaction_comment` TEXT, `transaction_comment_match_group` INTEGER, `date_year` INTEGER, `date_year_match_group` INTEGER, `date_month` INTEGER, `date_month_match_group` INTEGER, `date_day` INTEGER, `date_day_match_group` INTEGER)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "regularExpression",
+            "columnName": "regular_expression",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "testText",
+            "columnName": "test_text",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescription",
+            "columnName": "transaction_description",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescriptionMatchGroup",
+            "columnName": "transaction_description_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionComment",
+            "columnName": "transaction_comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionCommentMatchGroup",
+            "columnName": "transaction_comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYear",
+            "columnName": "date_year",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYearMatchGroup",
+            "columnName": "date_year_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonth",
+            "columnName": "date_month",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonthMatchGroup",
+            "columnName": "date_month_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDay",
+            "columnName": "date_day",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDayMatchGroup",
+            "columnName": "date_day_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_patterns_id",
+            "unique": true,
+            "columnNames": [
+              "id"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_patterns_id` ON `${TABLE_NAME}` (`id`)"
+          }
+        ],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "pattern_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pattern_id` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `acc` TEXT, `position` INTEGER NOT NULL, `acc_match_group` INTEGER, `currency` INTEGER, `currency_match_group` INTEGER, `amount` REAL, `amount_match_group` INTEGER, `comment` TEXT, `comment_match_group` INTEGER, FOREIGN KEY(`pattern_id`) REFERENCES `patterns`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`currency`) REFERENCES `currencies`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
+        "fields": [
+          {
+            "fieldPath": "patternId",
+            "columnName": "pattern_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "acc",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountNameMatchGroup",
+            "columnName": "acc_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currencyMatchGroup",
+            "columnName": "currency_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amountMatchGroup",
+            "columnName": "amount_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountComment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountCommentMatchGroup",
+            "columnName": "comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_pattern_accounts",
+            "unique": true,
+            "columnNames": [
+              "id"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_pattern_accounts` ON `${TABLE_NAME}` (`id`)"
+          },
+          {
+            "name": "fk_pattern_accounts_pattern",
+            "unique": false,
+            "columnNames": [
+              "pattern_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_pattern_accounts_pattern` ON `${TABLE_NAME}` (`pattern_id`)"
+          },
+          {
+            "name": "fk_pattern_accounts_currency",
+            "unique": false,
+            "columnNames": [
+              "currency"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_pattern_accounts_currency` ON `${TABLE_NAME}` (`currency`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "patterns",
+            "onDelete": "NO ACTION",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "pattern_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          },
+          {
+            "table": "currencies",
+            "onDelete": "NO ACTION",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "currency"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "currencies",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `position` TEXT NOT NULL, `has_gap` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "hasGap",
+            "columnName": "has_gap",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "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, 'd80c63258c511ee305dc5f0062a8af2a')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/app/schemas/net.ktnx.mobileledger.db.DB/52.json b/app/schemas/net.ktnx.mobileledger.db.DB/52.json
new file mode 100644 (file)
index 0000000..883df1f
--- /dev/null
@@ -0,0 +1,287 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 52,
+    "identityHash": "d80c63258c511ee305dc5f0062a8af2a",
+    "entities": [
+      {
+        "tableName": "patterns",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `regular_expression` TEXT NOT NULL, `test_text` TEXT, `transaction_description` TEXT, `transaction_description_match_group` INTEGER, `transaction_comment` TEXT, `transaction_comment_match_group` INTEGER, `date_year` INTEGER, `date_year_match_group` INTEGER, `date_month` INTEGER, `date_month_match_group` INTEGER, `date_day` INTEGER, `date_day_match_group` INTEGER)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "regularExpression",
+            "columnName": "regular_expression",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "testText",
+            "columnName": "test_text",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescription",
+            "columnName": "transaction_description",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescriptionMatchGroup",
+            "columnName": "transaction_description_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionComment",
+            "columnName": "transaction_comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionCommentMatchGroup",
+            "columnName": "transaction_comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYear",
+            "columnName": "date_year",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYearMatchGroup",
+            "columnName": "date_year_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonth",
+            "columnName": "date_month",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonthMatchGroup",
+            "columnName": "date_month_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDay",
+            "columnName": "date_day",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDayMatchGroup",
+            "columnName": "date_day_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_patterns_id",
+            "unique": true,
+            "columnNames": [
+              "id"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_patterns_id` ON `${TABLE_NAME}` (`id`)"
+          }
+        ],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "pattern_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pattern_id` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `acc` TEXT, `position` INTEGER NOT NULL, `acc_match_group` INTEGER, `currency` INTEGER, `currency_match_group` INTEGER, `amount` REAL, `amount_match_group` INTEGER, `comment` TEXT, `comment_match_group` INTEGER, FOREIGN KEY(`pattern_id`) REFERENCES `patterns`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`currency`) REFERENCES `currencies`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
+        "fields": [
+          {
+            "fieldPath": "patternId",
+            "columnName": "pattern_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "acc",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountNameMatchGroup",
+            "columnName": "acc_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currencyMatchGroup",
+            "columnName": "currency_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amountMatchGroup",
+            "columnName": "amount_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountComment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountCommentMatchGroup",
+            "columnName": "comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_pattern_accounts",
+            "unique": true,
+            "columnNames": [
+              "id"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_pattern_accounts` ON `${TABLE_NAME}` (`id`)"
+          },
+          {
+            "name": "fk_pattern_accounts_pattern",
+            "unique": false,
+            "columnNames": [
+              "pattern_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_pattern_accounts_pattern` ON `${TABLE_NAME}` (`pattern_id`)"
+          },
+          {
+            "name": "fk_pattern_accounts_currency",
+            "unique": false,
+            "columnNames": [
+              "currency"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_pattern_accounts_currency` ON `${TABLE_NAME}` (`currency`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "patterns",
+            "onDelete": "NO ACTION",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "pattern_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          },
+          {
+            "table": "currencies",
+            "onDelete": "NO ACTION",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "currency"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "currencies",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `position` TEXT NOT NULL, `has_gap` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "hasGap",
+            "columnName": "has_gap",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "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, 'd80c63258c511ee305dc5f0062a8af2a')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/app/schemas/net.ktnx.mobileledger.db.DB/53.json b/app/schemas/net.ktnx.mobileledger.db.DB/53.json
new file mode 100644 (file)
index 0000000..559d1ad
--- /dev/null
@@ -0,0 +1,293 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 53,
+    "identityHash": "be3773207074d191c145ae00acff65da",
+    "entities": [
+      {
+        "tableName": "patterns",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `regular_expression` TEXT NOT NULL, `test_text` TEXT, `transaction_description` TEXT, `transaction_description_match_group` INTEGER, `transaction_comment` TEXT, `transaction_comment_match_group` INTEGER, `date_year` INTEGER, `date_year_match_group` INTEGER, `date_month` INTEGER, `date_month_match_group` INTEGER, `date_day` INTEGER, `date_day_match_group` INTEGER)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "regularExpression",
+            "columnName": "regular_expression",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "testText",
+            "columnName": "test_text",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescription",
+            "columnName": "transaction_description",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescriptionMatchGroup",
+            "columnName": "transaction_description_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionComment",
+            "columnName": "transaction_comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionCommentMatchGroup",
+            "columnName": "transaction_comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYear",
+            "columnName": "date_year",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYearMatchGroup",
+            "columnName": "date_year_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonth",
+            "columnName": "date_month",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonthMatchGroup",
+            "columnName": "date_month_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDay",
+            "columnName": "date_day",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDayMatchGroup",
+            "columnName": "date_day_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_patterns_id",
+            "unique": true,
+            "columnNames": [
+              "id"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_patterns_id` ON `${TABLE_NAME}` (`id`)"
+          }
+        ],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "pattern_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pattern_id` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `acc` TEXT, `position` INTEGER NOT NULL, `acc_match_group` INTEGER, `currency` INTEGER, `currency_match_group` INTEGER, `amount` REAL, `amount_match_group` INTEGER, `comment` TEXT, `comment_match_group` INTEGER, `negate_amount` INTEGER, FOREIGN KEY(`pattern_id`) REFERENCES `patterns`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`currency`) REFERENCES `currencies`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
+        "fields": [
+          {
+            "fieldPath": "patternId",
+            "columnName": "pattern_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "acc",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountNameMatchGroup",
+            "columnName": "acc_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currencyMatchGroup",
+            "columnName": "currency_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amountMatchGroup",
+            "columnName": "amount_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountComment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountCommentMatchGroup",
+            "columnName": "comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "negateAmount",
+            "columnName": "negate_amount",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_pattern_accounts",
+            "unique": true,
+            "columnNames": [
+              "id"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_pattern_accounts` ON `${TABLE_NAME}` (`id`)"
+          },
+          {
+            "name": "fk_pattern_accounts_pattern",
+            "unique": false,
+            "columnNames": [
+              "pattern_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_pattern_accounts_pattern` ON `${TABLE_NAME}` (`pattern_id`)"
+          },
+          {
+            "name": "fk_pattern_accounts_currency",
+            "unique": false,
+            "columnNames": [
+              "currency"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_pattern_accounts_currency` ON `${TABLE_NAME}` (`currency`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "patterns",
+            "onDelete": "NO ACTION",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "pattern_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          },
+          {
+            "table": "currencies",
+            "onDelete": "NO ACTION",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "currency"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "currencies",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `position` TEXT NOT NULL, `has_gap` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "hasGap",
+            "columnName": "has_gap",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "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, 'be3773207074d191c145ae00acff65da')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/app/schemas/net.ktnx.mobileledger.db.DB/54.json b/app/schemas/net.ktnx.mobileledger.db.DB/54.json
new file mode 100644 (file)
index 0000000..fcaec8c
--- /dev/null
@@ -0,0 +1,276 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 54,
+    "identityHash": "c5ddfa995546d7931ec2655613654949",
+    "entities": [
+      {
+        "tableName": "templates",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `regular_expression` TEXT NOT NULL, `test_text` TEXT, `transaction_description` TEXT, `transaction_description_match_group` INTEGER, `transaction_comment` TEXT, `transaction_comment_match_group` INTEGER, `date_year` INTEGER, `date_year_match_group` INTEGER, `date_month` INTEGER, `date_month_match_group` INTEGER, `date_day` INTEGER, `date_day_match_group` INTEGER)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "regularExpression",
+            "columnName": "regular_expression",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "testText",
+            "columnName": "test_text",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescription",
+            "columnName": "transaction_description",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescriptionMatchGroup",
+            "columnName": "transaction_description_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionComment",
+            "columnName": "transaction_comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionCommentMatchGroup",
+            "columnName": "transaction_comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYear",
+            "columnName": "date_year",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYearMatchGroup",
+            "columnName": "date_year_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonth",
+            "columnName": "date_month",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonthMatchGroup",
+            "columnName": "date_month_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDay",
+            "columnName": "date_day",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDayMatchGroup",
+            "columnName": "date_day_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "template_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `template_id` INTEGER NOT NULL, `acc` TEXT, `position` INTEGER NOT NULL, `acc_match_group` INTEGER, `currency` INTEGER, `currency_match_group` INTEGER, `amount` REAL, `amount_match_group` INTEGER, `comment` TEXT, `comment_match_group` INTEGER, `negate_amount` INTEGER, FOREIGN KEY(`template_id`) REFERENCES `templates`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`currency`) REFERENCES `currencies`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "templateId",
+            "columnName": "template_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "acc",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountNameMatchGroup",
+            "columnName": "acc_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currencyMatchGroup",
+            "columnName": "currency_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amountMatchGroup",
+            "columnName": "amount_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountComment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountCommentMatchGroup",
+            "columnName": "comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "negateAmount",
+            "columnName": "negate_amount",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "fk_template_accounts_template",
+            "unique": false,
+            "columnNames": [
+              "template_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_template_accounts_template` ON `${TABLE_NAME}` (`template_id`)"
+          },
+          {
+            "name": "fk_template_accounts_currency",
+            "unique": false,
+            "columnNames": [
+              "currency"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_template_accounts_currency` ON `${TABLE_NAME}` (`currency`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "templates",
+            "onDelete": "NO ACTION",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "template_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          },
+          {
+            "table": "currencies",
+            "onDelete": "NO ACTION",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "currency"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "currencies",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `position` TEXT NOT NULL, `has_gap` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "hasGap",
+            "columnName": "has_gap",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "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, 'c5ddfa995546d7931ec2655613654949')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/app/schemas/net.ktnx.mobileledger.db.DB/55.json b/app/schemas/net.ktnx.mobileledger.db.DB/55.json
new file mode 100644 (file)
index 0000000..8c790a3
--- /dev/null
@@ -0,0 +1,276 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 55,
+    "identityHash": "ed75412e9453605c9829ad7f3269f62e",
+    "entities": [
+      {
+        "tableName": "templates",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `regular_expression` TEXT NOT NULL, `test_text` TEXT, `transaction_description` TEXT, `transaction_description_match_group` INTEGER, `transaction_comment` TEXT, `transaction_comment_match_group` INTEGER, `date_year` INTEGER, `date_year_match_group` INTEGER, `date_month` INTEGER, `date_month_match_group` INTEGER, `date_day` INTEGER, `date_day_match_group` INTEGER)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "regularExpression",
+            "columnName": "regular_expression",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "testText",
+            "columnName": "test_text",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescription",
+            "columnName": "transaction_description",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescriptionMatchGroup",
+            "columnName": "transaction_description_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionComment",
+            "columnName": "transaction_comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionCommentMatchGroup",
+            "columnName": "transaction_comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYear",
+            "columnName": "date_year",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYearMatchGroup",
+            "columnName": "date_year_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonth",
+            "columnName": "date_month",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonthMatchGroup",
+            "columnName": "date_month_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDay",
+            "columnName": "date_day",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDayMatchGroup",
+            "columnName": "date_day_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "template_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `template_id` INTEGER NOT NULL, `acc` TEXT, `position` INTEGER NOT NULL, `acc_match_group` INTEGER, `currency` INTEGER, `currency_match_group` INTEGER, `amount` REAL, `amount_match_group` INTEGER, `comment` TEXT, `comment_match_group` INTEGER, `negate_amount` INTEGER, FOREIGN KEY(`template_id`) REFERENCES `templates`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE , FOREIGN KEY(`currency`) REFERENCES `currencies`(`id`) ON UPDATE RESTRICT ON DELETE RESTRICT )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "templateId",
+            "columnName": "template_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "acc",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountNameMatchGroup",
+            "columnName": "acc_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currencyMatchGroup",
+            "columnName": "currency_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amountMatchGroup",
+            "columnName": "amount_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountComment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountCommentMatchGroup",
+            "columnName": "comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "negateAmount",
+            "columnName": "negate_amount",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "fk_template_accounts_template",
+            "unique": false,
+            "columnNames": [
+              "template_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_template_accounts_template` ON `${TABLE_NAME}` (`template_id`)"
+          },
+          {
+            "name": "fk_template_accounts_currency",
+            "unique": false,
+            "columnNames": [
+              "currency"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_template_accounts_currency` ON `${TABLE_NAME}` (`currency`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "templates",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "template_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          },
+          {
+            "table": "currencies",
+            "onDelete": "RESTRICT",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "currency"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "currencies",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `position` TEXT NOT NULL, `has_gap` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "hasGap",
+            "columnName": "has_gap",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "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, 'ed75412e9453605c9829ad7f3269f62e')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/app/schemas/net.ktnx.mobileledger.db.DB/56.json b/app/schemas/net.ktnx.mobileledger.db.DB/56.json
new file mode 100644 (file)
index 0000000..f60708c
--- /dev/null
@@ -0,0 +1,342 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 56,
+    "identityHash": "00c7b4a529107e23cd5925d75867f6d9",
+    "entities": [
+      {
+        "tableName": "templates",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `regular_expression` TEXT NOT NULL, `test_text` TEXT, `transaction_description` TEXT, `transaction_description_match_group` INTEGER, `transaction_comment` TEXT, `transaction_comment_match_group` INTEGER, `date_year` INTEGER, `date_year_match_group` INTEGER, `date_month` INTEGER, `date_month_match_group` INTEGER, `date_day` INTEGER, `date_day_match_group` INTEGER)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "regularExpression",
+            "columnName": "regular_expression",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "testText",
+            "columnName": "test_text",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescription",
+            "columnName": "transaction_description",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescriptionMatchGroup",
+            "columnName": "transaction_description_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionComment",
+            "columnName": "transaction_comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionCommentMatchGroup",
+            "columnName": "transaction_comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYear",
+            "columnName": "date_year",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYearMatchGroup",
+            "columnName": "date_year_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonth",
+            "columnName": "date_month",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonthMatchGroup",
+            "columnName": "date_month_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDay",
+            "columnName": "date_day",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDayMatchGroup",
+            "columnName": "date_day_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "template_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `template_id` INTEGER NOT NULL, `acc` TEXT, `position` INTEGER NOT NULL, `acc_match_group` INTEGER, `currency` INTEGER, `currency_match_group` INTEGER, `amount` REAL, `amount_match_group` INTEGER, `comment` TEXT, `comment_match_group` INTEGER, `negate_amount` INTEGER, FOREIGN KEY(`template_id`) REFERENCES `templates`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE , FOREIGN KEY(`currency`) REFERENCES `currencies`(`id`) ON UPDATE RESTRICT ON DELETE RESTRICT )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "templateId",
+            "columnName": "template_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "acc",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountNameMatchGroup",
+            "columnName": "acc_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currencyMatchGroup",
+            "columnName": "currency_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amountMatchGroup",
+            "columnName": "amount_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountComment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountCommentMatchGroup",
+            "columnName": "comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "negateAmount",
+            "columnName": "negate_amount",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "fk_template_accounts_template",
+            "unique": false,
+            "columnNames": [
+              "template_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_template_accounts_template` ON `${TABLE_NAME}` (`template_id`)"
+          },
+          {
+            "name": "fk_template_accounts_currency",
+            "unique": false,
+            "columnNames": [
+              "currency"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_template_accounts_currency` ON `${TABLE_NAME}` (`currency`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "templates",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "template_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          },
+          {
+            "table": "currencies",
+            "onDelete": "RESTRICT",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "currency"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "currencies",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `position` TEXT NOT NULL, `has_gap` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "hasGap",
+            "columnName": "has_gap",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`level` INTEGER NOT NULL, `profile` TEXT NOT NULL, `name` TEXT NOT NULL, `name_upper` TEXT NOT NULL, `parent_name` TEXT, `expanded` INTEGER NOT NULL DEFAULT 1, `amounts_expanded` INTEGER NOT NULL DEFAULT 0, `generation` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`profile`, `name`))",
+        "fields": [
+          {
+            "fieldPath": "level",
+            "columnName": "level",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "profile",
+            "columnName": "profile",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "nameUpper",
+            "columnName": "name_upper",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "parentName",
+            "columnName": "parent_name",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "expanded",
+            "columnName": "expanded",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "amountsExpanded",
+            "columnName": "amounts_expanded",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "profile",
+            "name"
+          ],
+          "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, '00c7b4a529107e23cd5925d75867f6d9')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/app/schemas/net.ktnx.mobileledger.db.DB/57.json b/app/schemas/net.ktnx.mobileledger.db.DB/57.json
new file mode 100644 (file)
index 0000000..68311f5
--- /dev/null
@@ -0,0 +1,348 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 57,
+    "identityHash": "5a5aa2f77594578d228d211d5e4406a6",
+    "entities": [
+      {
+        "tableName": "templates",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `regular_expression` TEXT NOT NULL, `test_text` TEXT, `transaction_description` TEXT, `transaction_description_match_group` INTEGER, `transaction_comment` TEXT, `transaction_comment_match_group` INTEGER, `date_year` INTEGER, `date_year_match_group` INTEGER, `date_month` INTEGER, `date_month_match_group` INTEGER, `date_day` INTEGER, `date_day_match_group` INTEGER, `is_fallback` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "regularExpression",
+            "columnName": "regular_expression",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "testText",
+            "columnName": "test_text",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescription",
+            "columnName": "transaction_description",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescriptionMatchGroup",
+            "columnName": "transaction_description_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionComment",
+            "columnName": "transaction_comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionCommentMatchGroup",
+            "columnName": "transaction_comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYear",
+            "columnName": "date_year",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYearMatchGroup",
+            "columnName": "date_year_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonth",
+            "columnName": "date_month",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonthMatchGroup",
+            "columnName": "date_month_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDay",
+            "columnName": "date_day",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDayMatchGroup",
+            "columnName": "date_day_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "isFallback",
+            "columnName": "is_fallback",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "template_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `template_id` INTEGER NOT NULL, `acc` TEXT, `position` INTEGER NOT NULL, `acc_match_group` INTEGER, `currency` INTEGER, `currency_match_group` INTEGER, `amount` REAL, `amount_match_group` INTEGER, `comment` TEXT, `comment_match_group` INTEGER, `negate_amount` INTEGER, FOREIGN KEY(`template_id`) REFERENCES `templates`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE , FOREIGN KEY(`currency`) REFERENCES `currencies`(`id`) ON UPDATE RESTRICT ON DELETE RESTRICT )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "templateId",
+            "columnName": "template_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "acc",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountNameMatchGroup",
+            "columnName": "acc_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currencyMatchGroup",
+            "columnName": "currency_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amountMatchGroup",
+            "columnName": "amount_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountComment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountCommentMatchGroup",
+            "columnName": "comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "negateAmount",
+            "columnName": "negate_amount",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "fk_template_accounts_template",
+            "unique": false,
+            "columnNames": [
+              "template_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_template_accounts_template` ON `${TABLE_NAME}` (`template_id`)"
+          },
+          {
+            "name": "fk_template_accounts_currency",
+            "unique": false,
+            "columnNames": [
+              "currency"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_template_accounts_currency` ON `${TABLE_NAME}` (`currency`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "templates",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "template_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          },
+          {
+            "table": "currencies",
+            "onDelete": "RESTRICT",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "currency"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "currencies",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `position` TEXT NOT NULL, `has_gap` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "hasGap",
+            "columnName": "has_gap",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`level` INTEGER NOT NULL, `profile` TEXT NOT NULL, `name` TEXT NOT NULL, `name_upper` TEXT NOT NULL, `parent_name` TEXT, `expanded` INTEGER NOT NULL DEFAULT 1, `amounts_expanded` INTEGER NOT NULL DEFAULT 0, `generation` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`profile`, `name`))",
+        "fields": [
+          {
+            "fieldPath": "level",
+            "columnName": "level",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "profile",
+            "columnName": "profile",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "nameUpper",
+            "columnName": "name_upper",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "parentName",
+            "columnName": "parent_name",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "expanded",
+            "columnName": "expanded",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "amountsExpanded",
+            "columnName": "amounts_expanded",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "profile",
+            "name"
+          ],
+          "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, '5a5aa2f77594578d228d211d5e4406a6')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/app/schemas/net.ktnx.mobileledger.db.DB/58.json b/app/schemas/net.ktnx.mobileledger.db.DB/58.json
new file mode 100644 (file)
index 0000000..c4dea2e
--- /dev/null
@@ -0,0 +1,776 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 58,
+    "identityHash": "0f584c8b143be77895cc315ffbc41f3e",
+    "entities": [
+      {
+        "tableName": "templates",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `regular_expression` TEXT NOT NULL, `test_text` TEXT, `transaction_description` TEXT, `transaction_description_match_group` INTEGER, `transaction_comment` TEXT, `transaction_comment_match_group` INTEGER, `date_year` INTEGER, `date_year_match_group` INTEGER, `date_month` INTEGER, `date_month_match_group` INTEGER, `date_day` INTEGER, `date_day_match_group` INTEGER, `is_fallback` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "regularExpression",
+            "columnName": "regular_expression",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "testText",
+            "columnName": "test_text",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescription",
+            "columnName": "transaction_description",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescriptionMatchGroup",
+            "columnName": "transaction_description_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionComment",
+            "columnName": "transaction_comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionCommentMatchGroup",
+            "columnName": "transaction_comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYear",
+            "columnName": "date_year",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYearMatchGroup",
+            "columnName": "date_year_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonth",
+            "columnName": "date_month",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonthMatchGroup",
+            "columnName": "date_month_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDay",
+            "columnName": "date_day",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDayMatchGroup",
+            "columnName": "date_day_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "isFallback",
+            "columnName": "is_fallback",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "template_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `template_id` INTEGER NOT NULL, `acc` TEXT, `position` INTEGER NOT NULL, `acc_match_group` INTEGER, `currency` INTEGER, `currency_match_group` INTEGER, `amount` REAL, `amount_match_group` INTEGER, `comment` TEXT, `comment_match_group` INTEGER, `negate_amount` INTEGER, FOREIGN KEY(`template_id`) REFERENCES `templates`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE , FOREIGN KEY(`currency`) REFERENCES `currencies`(`id`) ON UPDATE RESTRICT ON DELETE RESTRICT )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "templateId",
+            "columnName": "template_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "acc",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountNameMatchGroup",
+            "columnName": "acc_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currencyMatchGroup",
+            "columnName": "currency_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amountMatchGroup",
+            "columnName": "amount_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountComment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountCommentMatchGroup",
+            "columnName": "comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "negateAmount",
+            "columnName": "negate_amount",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "fk_template_accounts_template",
+            "unique": false,
+            "columnNames": [
+              "template_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_template_accounts_template` ON `${TABLE_NAME}` (`template_id`)"
+          },
+          {
+            "name": "fk_template_accounts_currency",
+            "unique": false,
+            "columnNames": [
+              "currency"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_template_accounts_currency` ON `${TABLE_NAME}` (`currency`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "templates",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "template_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          },
+          {
+            "table": "currencies",
+            "onDelete": "RESTRICT",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "currency"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "currencies",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `position` TEXT NOT NULL, `has_gap` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "hasGap",
+            "columnName": "has_gap",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`level` INTEGER NOT NULL, `profile` TEXT NOT NULL, `name` TEXT NOT NULL, `name_upper` TEXT NOT NULL, `parent_name` TEXT, `expanded` INTEGER NOT NULL DEFAULT 1, `amounts_expanded` INTEGER NOT NULL DEFAULT 0, `generation` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`profile`, `name`))",
+        "fields": [
+          {
+            "fieldPath": "level",
+            "columnName": "level",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "profile",
+            "columnName": "profile",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "nameUpper",
+            "columnName": "name_upper",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "parentName",
+            "columnName": "parent_name",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "expanded",
+            "columnName": "expanded",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "amountsExpanded",
+            "columnName": "amounts_expanded",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "profile",
+            "name"
+          ],
+          "autoGenerate": false
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "profiles",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `use_authentication` INTEGER NOT NULL, `auth_user` TEXT, `auth_password` TEXT, `order_no` INTEGER NOT NULL, `permit_posting` INTEGER NOT NULL, `theme` INTEGER NOT NULL DEFAULT -1, `preferred_accounts_filter` TEXT, `future_dates` INTEGER NOT NULL, `api_version` INTEGER NOT NULL, `show_commodity_by_default` INTEGER NOT NULL, `default_commodity` TEXT, `show_comments_by_default` INTEGER NOT NULL DEFAULT 1, `detected_version_pre_1_19` INTEGER NOT NULL, `detected_version_major` INTEGER NOT NULL, `detected_version_minor` INTEGER NOT NULL, PRIMARY KEY(`uuid`))",
+        "fields": [
+          {
+            "fieldPath": "uuid",
+            "columnName": "uuid",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "useAuthentication",
+            "columnName": "use_authentication",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "authUser",
+            "columnName": "auth_user",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "authPassword",
+            "columnName": "auth_password",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "orderNo",
+            "columnName": "order_no",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "permitPosting",
+            "columnName": "permit_posting",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "theme",
+            "columnName": "theme",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "-1"
+          },
+          {
+            "fieldPath": "preferredAccountsFilter",
+            "columnName": "preferred_accounts_filter",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "futureDates",
+            "columnName": "future_dates",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "apiVersion",
+            "columnName": "api_version",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "showCommodityByDefault",
+            "columnName": "show_commodity_by_default",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "defaultCommodity",
+            "columnName": "default_commodity",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "showCommentsByDefault",
+            "columnName": "show_comments_by_default",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "detectedVersionPre_1_19",
+            "columnName": "detected_version_pre_1_19",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "detectedVersionMajor",
+            "columnName": "detected_version_major",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "detectedVersionMinor",
+            "columnName": "detected_version_minor",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "uuid"
+          ],
+          "autoGenerate": false
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "options",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profile` TEXT NOT NULL, `name` TEXT NOT NULL, `value` TEXT, PRIMARY KEY(`profile`, `name`))",
+        "fields": [
+          {
+            "fieldPath": "profile",
+            "columnName": "profile",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "profile",
+            "name"
+          ],
+          "autoGenerate": false
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "account_values",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profile` TEXT NOT NULL, `account` TEXT NOT NULL, `currency` TEXT NOT NULL DEFAULT '', `value` REAL NOT NULL, `generation` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`profile`, `account`, `currency`))",
+        "fields": [
+          {
+            "fieldPath": "profile",
+            "columnName": "profile",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "account",
+            "columnName": "account",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "profile",
+            "account",
+            "currency"
+          ],
+          "autoGenerate": false
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "description_history",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`description` TEXT NOT NULL COLLATE NOCASE, `description_upper` TEXT NOT NULL, `generation` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`description`))",
+        "fields": [
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "descriptionUpper",
+            "columnName": "description_upper",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "description"
+          ],
+          "autoGenerate": false
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "transactions",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profile` TEXT NOT NULL, `id` INTEGER NOT NULL, `data_hash` TEXT NOT NULL, `year` INTEGER NOT NULL, `month` INTEGER NOT NULL, `day` INTEGER NOT NULL, `description` TEXT NOT NULL COLLATE NOCASE, `comment` TEXT, `generation` INTEGER NOT NULL, PRIMARY KEY(`profile`, `id`))",
+        "fields": [
+          {
+            "fieldPath": "profile",
+            "columnName": "profile",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "dataHash",
+            "columnName": "data_hash",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "year",
+            "columnName": "year",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "month",
+            "columnName": "month",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "day",
+            "columnName": "day",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "comment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "profile",
+            "id"
+          ],
+          "autoGenerate": false
+        },
+        "indices": [
+          {
+            "name": "un_transactions_data_hash",
+            "unique": true,
+            "columnNames": [
+              "profile",
+              "data_hash"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_transactions_data_hash` ON `${TABLE_NAME}` (`profile`, `data_hash`)"
+          },
+          {
+            "name": "idx_transaction_description",
+            "unique": false,
+            "columnNames": [
+              "description"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `idx_transaction_description` ON `${TABLE_NAME}` (`description`)"
+          }
+        ],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "transaction_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profile` TEXT NOT NULL, `transaction_id` INTEGER NOT NULL, `order_no` INTEGER NOT NULL, `account_name` TEXT NOT NULL, `currency` TEXT NOT NULL DEFAULT '', `amount` REAL NOT NULL, `comment` TEXT, `generation` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`profile`, `transaction_id`, `order_no`), FOREIGN KEY(`profile`, `transaction_id`) REFERENCES `transactions`(`profile`, `id`) ON UPDATE RESTRICT ON DELETE CASCADE , FOREIGN KEY(`profile`, `account_name`) REFERENCES `accounts`(`profile`, `name`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "profile",
+            "columnName": "profile",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "transactionId",
+            "columnName": "transaction_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "orderNo",
+            "columnName": "order_no",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "account_name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "comment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "profile",
+            "transaction_id",
+            "order_no"
+          ],
+          "autoGenerate": false
+        },
+        "indices": [
+          {
+            "name": "fk_tran_acc_prof_acc",
+            "unique": false,
+            "columnNames": [
+              "profile",
+              "account_name"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_tran_acc_prof_acc` ON `${TABLE_NAME}` (`profile`, `account_name`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "transactions",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "profile",
+              "transaction_id"
+            ],
+            "referencedColumns": [
+              "profile",
+              "id"
+            ]
+          },
+          {
+            "table": "accounts",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "profile",
+              "account_name"
+            ],
+            "referencedColumns": [
+              "profile",
+              "name"
+            ]
+          }
+        ]
+      }
+    ],
+    "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, '0f584c8b143be77895cc315ffbc41f3e')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/app/schemas/net.ktnx.mobileledger.db.DB/59.json b/app/schemas/net.ktnx.mobileledger.db.DB/59.json
new file mode 100644 (file)
index 0000000..5896bca
--- /dev/null
@@ -0,0 +1,861 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 59,
+    "identityHash": "0ab4d8a73295b6337c52ea561994b1c8",
+    "entities": [
+      {
+        "tableName": "templates",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `regular_expression` TEXT NOT NULL, `test_text` TEXT, `transaction_description` TEXT, `transaction_description_match_group` INTEGER, `transaction_comment` TEXT, `transaction_comment_match_group` INTEGER, `date_year` INTEGER, `date_year_match_group` INTEGER, `date_month` INTEGER, `date_month_match_group` INTEGER, `date_day` INTEGER, `date_day_match_group` INTEGER, `is_fallback` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "regularExpression",
+            "columnName": "regular_expression",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "testText",
+            "columnName": "test_text",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescription",
+            "columnName": "transaction_description",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescriptionMatchGroup",
+            "columnName": "transaction_description_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionComment",
+            "columnName": "transaction_comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionCommentMatchGroup",
+            "columnName": "transaction_comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYear",
+            "columnName": "date_year",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYearMatchGroup",
+            "columnName": "date_year_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonth",
+            "columnName": "date_month",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonthMatchGroup",
+            "columnName": "date_month_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDay",
+            "columnName": "date_day",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDayMatchGroup",
+            "columnName": "date_day_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "isFallback",
+            "columnName": "is_fallback",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "template_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `template_id` INTEGER NOT NULL, `acc` TEXT, `position` INTEGER NOT NULL, `acc_match_group` INTEGER, `currency` INTEGER, `currency_match_group` INTEGER, `amount` REAL, `amount_match_group` INTEGER, `comment` TEXT, `comment_match_group` INTEGER, `negate_amount` INTEGER, FOREIGN KEY(`template_id`) REFERENCES `templates`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE , FOREIGN KEY(`currency`) REFERENCES `currencies`(`id`) ON UPDATE RESTRICT ON DELETE RESTRICT )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "templateId",
+            "columnName": "template_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "acc",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountNameMatchGroup",
+            "columnName": "acc_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currencyMatchGroup",
+            "columnName": "currency_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amountMatchGroup",
+            "columnName": "amount_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountComment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountCommentMatchGroup",
+            "columnName": "comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "negateAmount",
+            "columnName": "negate_amount",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "fk_template_accounts_template",
+            "unique": false,
+            "columnNames": [
+              "template_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_template_accounts_template` ON `${TABLE_NAME}` (`template_id`)"
+          },
+          {
+            "name": "fk_template_accounts_currency",
+            "unique": false,
+            "columnNames": [
+              "currency"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_template_accounts_currency` ON `${TABLE_NAME}` (`currency`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "templates",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "template_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          },
+          {
+            "table": "currencies",
+            "onDelete": "RESTRICT",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "currency"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "currencies",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `position` TEXT NOT NULL, `has_gap` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "hasGap",
+            "columnName": "has_gap",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `profile_id` INTEGER NOT NULL, `level` INTEGER NOT NULL, `name` TEXT NOT NULL, `name_upper` TEXT NOT NULL, `parent_name` TEXT, `expanded` INTEGER NOT NULL DEFAULT 1, `amounts_expanded` INTEGER NOT NULL DEFAULT 0, `generation` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`profile_id`) REFERENCES `profiles`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "profileId",
+            "columnName": "profile_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "level",
+            "columnName": "level",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "nameUpper",
+            "columnName": "name_upper",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "parentName",
+            "columnName": "parent_name",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "expanded",
+            "columnName": "expanded",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "amountsExpanded",
+            "columnName": "amounts_expanded",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_account_name",
+            "unique": true,
+            "columnNames": [
+              "profile_id",
+              "name"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_account_name` ON `${TABLE_NAME}` (`profile_id`, `name`)"
+          },
+          {
+            "name": "fk_account_profile",
+            "unique": false,
+            "columnNames": [
+              "profile_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_account_profile` ON `${TABLE_NAME}` (`profile_id`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "profiles",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "profile_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "profiles",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `deprecated_uuid` TEXT, `url` TEXT NOT NULL, `use_authentication` INTEGER NOT NULL, `auth_user` TEXT, `auth_password` TEXT, `order_no` INTEGER NOT NULL, `permit_posting` INTEGER NOT NULL, `theme` INTEGER NOT NULL DEFAULT -1, `preferred_accounts_filter` TEXT, `future_dates` INTEGER NOT NULL, `api_version` INTEGER NOT NULL, `show_commodity_by_default` INTEGER NOT NULL, `default_commodity` TEXT, `show_comments_by_default` INTEGER NOT NULL DEFAULT 1, `detected_version_pre_1_19` INTEGER NOT NULL, `detected_version_major` INTEGER NOT NULL, `detected_version_minor` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "deprecatedUUID",
+            "columnName": "deprecated_uuid",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "useAuthentication",
+            "columnName": "use_authentication",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "authUser",
+            "columnName": "auth_user",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "authPassword",
+            "columnName": "auth_password",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "orderNo",
+            "columnName": "order_no",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "permitPosting",
+            "columnName": "permit_posting",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "theme",
+            "columnName": "theme",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "-1"
+          },
+          {
+            "fieldPath": "preferredAccountsFilter",
+            "columnName": "preferred_accounts_filter",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "futureDates",
+            "columnName": "future_dates",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "apiVersion",
+            "columnName": "api_version",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "showCommodityByDefault",
+            "columnName": "show_commodity_by_default",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "defaultCommodity",
+            "columnName": "default_commodity",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "showCommentsByDefault",
+            "columnName": "show_comments_by_default",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "detectedVersionPre_1_19",
+            "columnName": "detected_version_pre_1_19",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "detectedVersionMajor",
+            "columnName": "detected_version_major",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "detectedVersionMinor",
+            "columnName": "detected_version_minor",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "options",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profile_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `value` TEXT, PRIMARY KEY(`profile_id`, `name`))",
+        "fields": [
+          {
+            "fieldPath": "profileId",
+            "columnName": "profile_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "profile_id",
+            "name"
+          ],
+          "autoGenerate": false
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "account_values",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `account_id` INTEGER NOT NULL, `currency` TEXT NOT NULL DEFAULT '', `value` REAL NOT NULL, `generation` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountId",
+            "columnName": "account_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_account_values",
+            "unique": true,
+            "columnNames": [
+              "account_id",
+              "currency"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_account_values` ON `${TABLE_NAME}` (`account_id`, `currency`)"
+          },
+          {
+            "name": "fk_account_value_acc",
+            "unique": false,
+            "columnNames": [
+              "account_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_account_value_acc` ON `${TABLE_NAME}` (`account_id`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "accounts",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "account_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "description_history",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`description` TEXT NOT NULL COLLATE NOCASE, `description_upper` TEXT NOT NULL, `generation` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`description`))",
+        "fields": [
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "descriptionUpper",
+            "columnName": "description_upper",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "description"
+          ],
+          "autoGenerate": false
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "transactions",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ledger_id` INTEGER NOT NULL, `profile_id` INTEGER NOT NULL, `data_hash` TEXT NOT NULL, `year` INTEGER NOT NULL, `month` INTEGER NOT NULL, `day` INTEGER NOT NULL, `description` TEXT NOT NULL COLLATE NOCASE, `comment` TEXT, `generation` INTEGER NOT NULL, FOREIGN KEY(`profile_id`) REFERENCES `profiles`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "ledgerId",
+            "columnName": "ledger_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "profileId",
+            "columnName": "profile_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "dataHash",
+            "columnName": "data_hash",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "year",
+            "columnName": "year",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "month",
+            "columnName": "month",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "day",
+            "columnName": "day",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "comment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_transactions_ledger_id",
+            "unique": true,
+            "columnNames": [
+              "profile_id",
+              "ledger_id"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_transactions_ledger_id` ON `${TABLE_NAME}` (`profile_id`, `ledger_id`)"
+          },
+          {
+            "name": "idx_transaction_description",
+            "unique": false,
+            "columnNames": [
+              "description"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `idx_transaction_description` ON `${TABLE_NAME}` (`description`)"
+          },
+          {
+            "name": "fk_transaction_profile",
+            "unique": false,
+            "columnNames": [
+              "profile_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_transaction_profile` ON `${TABLE_NAME}` (`profile_id`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "profiles",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "profile_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "transaction_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `transaction_id` INTEGER NOT NULL, `order_no` INTEGER NOT NULL, `account_name` TEXT NOT NULL, `currency` TEXT NOT NULL DEFAULT '', `amount` REAL NOT NULL, `comment` TEXT, `generation` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`transaction_id`) REFERENCES `transactions`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "transactionId",
+            "columnName": "transaction_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "orderNo",
+            "columnName": "order_no",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "account_name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "comment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "fk_trans_acc_trans",
+            "unique": false,
+            "columnNames": [
+              "transaction_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_trans_acc_trans` ON `${TABLE_NAME}` (`transaction_id`)"
+          },
+          {
+            "name": "un_transaction_accounts",
+            "unique": true,
+            "columnNames": [
+              "transaction_id",
+              "order_no"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_transaction_accounts` ON `${TABLE_NAME}` (`transaction_id`, `order_no`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "transactions",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "transaction_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      }
+    ],
+    "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, '0ab4d8a73295b6337c52ea561994b1c8')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/app/schemas/net.ktnx.mobileledger.db.DB/60.json b/app/schemas/net.ktnx.mobileledger.db.DB/60.json
new file mode 100644 (file)
index 0000000..f65c93d
--- /dev/null
@@ -0,0 +1,828 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 60,
+    "identityHash": "b52e9a5f88719ae4a44e047ce81b34ee",
+    "entities": [
+      {
+        "tableName": "templates",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `regular_expression` TEXT NOT NULL, `test_text` TEXT, `transaction_description` TEXT, `transaction_description_match_group` INTEGER, `transaction_comment` TEXT, `transaction_comment_match_group` INTEGER, `date_year` INTEGER, `date_year_match_group` INTEGER, `date_month` INTEGER, `date_month_match_group` INTEGER, `date_day` INTEGER, `date_day_match_group` INTEGER, `is_fallback` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "regularExpression",
+            "columnName": "regular_expression",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "testText",
+            "columnName": "test_text",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescription",
+            "columnName": "transaction_description",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescriptionMatchGroup",
+            "columnName": "transaction_description_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionComment",
+            "columnName": "transaction_comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionCommentMatchGroup",
+            "columnName": "transaction_comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYear",
+            "columnName": "date_year",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYearMatchGroup",
+            "columnName": "date_year_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonth",
+            "columnName": "date_month",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonthMatchGroup",
+            "columnName": "date_month_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDay",
+            "columnName": "date_day",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDayMatchGroup",
+            "columnName": "date_day_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "isFallback",
+            "columnName": "is_fallback",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "template_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `template_id` INTEGER NOT NULL, `acc` TEXT, `position` INTEGER NOT NULL, `acc_match_group` INTEGER, `currency` INTEGER, `currency_match_group` INTEGER, `amount` REAL, `amount_match_group` INTEGER, `comment` TEXT, `comment_match_group` INTEGER, `negate_amount` INTEGER, FOREIGN KEY(`template_id`) REFERENCES `templates`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE , FOREIGN KEY(`currency`) REFERENCES `currencies`(`id`) ON UPDATE RESTRICT ON DELETE RESTRICT )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "templateId",
+            "columnName": "template_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "acc",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountNameMatchGroup",
+            "columnName": "acc_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currencyMatchGroup",
+            "columnName": "currency_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amountMatchGroup",
+            "columnName": "amount_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountComment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountCommentMatchGroup",
+            "columnName": "comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "negateAmount",
+            "columnName": "negate_amount",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "fk_template_accounts_template",
+            "unique": false,
+            "columnNames": [
+              "template_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_template_accounts_template` ON `${TABLE_NAME}` (`template_id`)"
+          },
+          {
+            "name": "fk_template_accounts_currency",
+            "unique": false,
+            "columnNames": [
+              "currency"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_template_accounts_currency` ON `${TABLE_NAME}` (`currency`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "templates",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "template_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          },
+          {
+            "table": "currencies",
+            "onDelete": "RESTRICT",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "currency"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "currencies",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `position` TEXT NOT NULL, `has_gap` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "hasGap",
+            "columnName": "has_gap",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `profile_id` INTEGER NOT NULL, `level` INTEGER NOT NULL, `name` TEXT NOT NULL, `name_upper` TEXT NOT NULL, `parent_name` TEXT, `expanded` INTEGER NOT NULL DEFAULT 1, `amounts_expanded` INTEGER NOT NULL DEFAULT 0, `generation` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`profile_id`) REFERENCES `profiles`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "profileId",
+            "columnName": "profile_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "level",
+            "columnName": "level",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "nameUpper",
+            "columnName": "name_upper",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "parentName",
+            "columnName": "parent_name",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "expanded",
+            "columnName": "expanded",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "amountsExpanded",
+            "columnName": "amounts_expanded",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_account_name",
+            "unique": true,
+            "columnNames": [
+              "profile_id",
+              "name"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_account_name` ON `${TABLE_NAME}` (`profile_id`, `name`)"
+          },
+          {
+            "name": "fk_account_profile",
+            "unique": false,
+            "columnNames": [
+              "profile_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_account_profile` ON `${TABLE_NAME}` (`profile_id`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "profiles",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "profile_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "profiles",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `deprecated_uuid` TEXT, `url` TEXT NOT NULL, `use_authentication` INTEGER NOT NULL, `auth_user` TEXT, `auth_password` TEXT, `order_no` INTEGER NOT NULL, `permit_posting` INTEGER NOT NULL, `theme` INTEGER NOT NULL DEFAULT -1, `preferred_accounts_filter` TEXT, `future_dates` INTEGER NOT NULL, `api_version` INTEGER NOT NULL, `show_commodity_by_default` INTEGER NOT NULL, `default_commodity` TEXT, `show_comments_by_default` INTEGER NOT NULL DEFAULT 1, `detected_version_pre_1_19` INTEGER NOT NULL, `detected_version_major` INTEGER NOT NULL, `detected_version_minor` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "deprecatedUUID",
+            "columnName": "deprecated_uuid",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "useAuthentication",
+            "columnName": "use_authentication",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "authUser",
+            "columnName": "auth_user",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "authPassword",
+            "columnName": "auth_password",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "orderNo",
+            "columnName": "order_no",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "permitPosting",
+            "columnName": "permit_posting",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "theme",
+            "columnName": "theme",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "-1"
+          },
+          {
+            "fieldPath": "preferredAccountsFilter",
+            "columnName": "preferred_accounts_filter",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "futureDates",
+            "columnName": "future_dates",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "apiVersion",
+            "columnName": "api_version",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "showCommodityByDefault",
+            "columnName": "show_commodity_by_default",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "defaultCommodity",
+            "columnName": "default_commodity",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "showCommentsByDefault",
+            "columnName": "show_comments_by_default",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "detectedVersionPre_1_19",
+            "columnName": "detected_version_pre_1_19",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "detectedVersionMajor",
+            "columnName": "detected_version_major",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "detectedVersionMinor",
+            "columnName": "detected_version_minor",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "options",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profile_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `value` TEXT, PRIMARY KEY(`profile_id`, `name`))",
+        "fields": [
+          {
+            "fieldPath": "profileId",
+            "columnName": "profile_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "profile_id",
+            "name"
+          ],
+          "autoGenerate": false
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "account_values",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `account_id` INTEGER NOT NULL, `currency` TEXT NOT NULL DEFAULT '', `value` REAL NOT NULL, `generation` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountId",
+            "columnName": "account_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_account_values",
+            "unique": true,
+            "columnNames": [
+              "account_id",
+              "currency"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_account_values` ON `${TABLE_NAME}` (`account_id`, `currency`)"
+          },
+          {
+            "name": "fk_account_value_acc",
+            "unique": false,
+            "columnNames": [
+              "account_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_account_value_acc` ON `${TABLE_NAME}` (`account_id`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "accounts",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "account_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "transactions",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ledger_id` INTEGER NOT NULL, `profile_id` INTEGER NOT NULL, `data_hash` TEXT NOT NULL, `year` INTEGER NOT NULL, `month` INTEGER NOT NULL, `day` INTEGER NOT NULL, `description` TEXT NOT NULL COLLATE NOCASE, `comment` TEXT, `generation` INTEGER NOT NULL, FOREIGN KEY(`profile_id`) REFERENCES `profiles`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "ledgerId",
+            "columnName": "ledger_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "profileId",
+            "columnName": "profile_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "dataHash",
+            "columnName": "data_hash",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "year",
+            "columnName": "year",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "month",
+            "columnName": "month",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "day",
+            "columnName": "day",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "comment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_transactions_ledger_id",
+            "unique": true,
+            "columnNames": [
+              "profile_id",
+              "ledger_id"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_transactions_ledger_id` ON `${TABLE_NAME}` (`profile_id`, `ledger_id`)"
+          },
+          {
+            "name": "idx_transaction_description",
+            "unique": false,
+            "columnNames": [
+              "description"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `idx_transaction_description` ON `${TABLE_NAME}` (`description`)"
+          },
+          {
+            "name": "fk_transaction_profile",
+            "unique": false,
+            "columnNames": [
+              "profile_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_transaction_profile` ON `${TABLE_NAME}` (`profile_id`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "profiles",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "profile_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "transaction_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `transaction_id` INTEGER NOT NULL, `order_no` INTEGER NOT NULL, `account_name` TEXT NOT NULL, `currency` TEXT NOT NULL DEFAULT '', `amount` REAL NOT NULL, `comment` TEXT, `generation` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`transaction_id`) REFERENCES `transactions`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "transactionId",
+            "columnName": "transaction_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "orderNo",
+            "columnName": "order_no",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "account_name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "comment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "fk_trans_acc_trans",
+            "unique": false,
+            "columnNames": [
+              "transaction_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_trans_acc_trans` ON `${TABLE_NAME}` (`transaction_id`)"
+          },
+          {
+            "name": "un_transaction_accounts",
+            "unique": true,
+            "columnNames": [
+              "transaction_id",
+              "order_no"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_transaction_accounts` ON `${TABLE_NAME}` (`transaction_id`, `order_no`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "transactions",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "transaction_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      }
+    ],
+    "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, 'b52e9a5f88719ae4a44e047ce81b34ee')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/app/schemas/net.ktnx.mobileledger.db.DB/61.json b/app/schemas/net.ktnx.mobileledger.db.DB/61.json
new file mode 100644 (file)
index 0000000..3a92716
--- /dev/null
@@ -0,0 +1,834 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 61,
+    "identityHash": "0549e893bdbb2c7eb5666c3ee81091d6",
+    "entities": [
+      {
+        "tableName": "templates",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `regular_expression` TEXT NOT NULL, `test_text` TEXT, `transaction_description` TEXT, `transaction_description_match_group` INTEGER, `transaction_comment` TEXT, `transaction_comment_match_group` INTEGER, `date_year` INTEGER, `date_year_match_group` INTEGER, `date_month` INTEGER, `date_month_match_group` INTEGER, `date_day` INTEGER, `date_day_match_group` INTEGER, `is_fallback` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "regularExpression",
+            "columnName": "regular_expression",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "testText",
+            "columnName": "test_text",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescription",
+            "columnName": "transaction_description",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescriptionMatchGroup",
+            "columnName": "transaction_description_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionComment",
+            "columnName": "transaction_comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionCommentMatchGroup",
+            "columnName": "transaction_comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYear",
+            "columnName": "date_year",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYearMatchGroup",
+            "columnName": "date_year_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonth",
+            "columnName": "date_month",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonthMatchGroup",
+            "columnName": "date_month_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDay",
+            "columnName": "date_day",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDayMatchGroup",
+            "columnName": "date_day_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "isFallback",
+            "columnName": "is_fallback",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "template_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `template_id` INTEGER NOT NULL, `acc` TEXT, `position` INTEGER NOT NULL, `acc_match_group` INTEGER, `currency` INTEGER, `currency_match_group` INTEGER, `amount` REAL, `amount_match_group` INTEGER, `comment` TEXT, `comment_match_group` INTEGER, `negate_amount` INTEGER, FOREIGN KEY(`template_id`) REFERENCES `templates`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE , FOREIGN KEY(`currency`) REFERENCES `currencies`(`id`) ON UPDATE RESTRICT ON DELETE RESTRICT )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "templateId",
+            "columnName": "template_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "acc",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountNameMatchGroup",
+            "columnName": "acc_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currencyMatchGroup",
+            "columnName": "currency_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amountMatchGroup",
+            "columnName": "amount_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountComment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountCommentMatchGroup",
+            "columnName": "comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "negateAmount",
+            "columnName": "negate_amount",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "fk_template_accounts_template",
+            "unique": false,
+            "columnNames": [
+              "template_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_template_accounts_template` ON `${TABLE_NAME}` (`template_id`)"
+          },
+          {
+            "name": "fk_template_accounts_currency",
+            "unique": false,
+            "columnNames": [
+              "currency"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_template_accounts_currency` ON `${TABLE_NAME}` (`currency`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "templates",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "template_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          },
+          {
+            "table": "currencies",
+            "onDelete": "RESTRICT",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "currency"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "currencies",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `position` TEXT NOT NULL, `has_gap` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "hasGap",
+            "columnName": "has_gap",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `profile_id` INTEGER NOT NULL, `level` INTEGER NOT NULL, `name` TEXT NOT NULL, `name_upper` TEXT NOT NULL, `parent_name` TEXT, `expanded` INTEGER NOT NULL DEFAULT 1, `amounts_expanded` INTEGER NOT NULL DEFAULT 0, `generation` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`profile_id`) REFERENCES `profiles`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "profileId",
+            "columnName": "profile_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "level",
+            "columnName": "level",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "nameUpper",
+            "columnName": "name_upper",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "parentName",
+            "columnName": "parent_name",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "expanded",
+            "columnName": "expanded",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "amountsExpanded",
+            "columnName": "amounts_expanded",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_account_name",
+            "unique": true,
+            "columnNames": [
+              "profile_id",
+              "name"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_account_name` ON `${TABLE_NAME}` (`profile_id`, `name`)"
+          },
+          {
+            "name": "fk_account_profile",
+            "unique": false,
+            "columnNames": [
+              "profile_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_account_profile` ON `${TABLE_NAME}` (`profile_id`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "profiles",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "profile_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "profiles",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `deprecated_uuid` TEXT, `url` TEXT NOT NULL, `use_authentication` INTEGER NOT NULL, `auth_user` TEXT, `auth_password` TEXT, `order_no` INTEGER NOT NULL, `permit_posting` INTEGER NOT NULL, `theme` INTEGER NOT NULL DEFAULT -1, `preferred_accounts_filter` TEXT, `future_dates` INTEGER NOT NULL, `api_version` INTEGER NOT NULL, `show_commodity_by_default` INTEGER NOT NULL, `default_commodity` TEXT, `show_comments_by_default` INTEGER NOT NULL DEFAULT 1, `detected_version_pre_1_19` INTEGER NOT NULL, `detected_version_major` INTEGER NOT NULL, `detected_version_minor` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "deprecatedUUID",
+            "columnName": "deprecated_uuid",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "useAuthentication",
+            "columnName": "use_authentication",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "authUser",
+            "columnName": "auth_user",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "authPassword",
+            "columnName": "auth_password",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "orderNo",
+            "columnName": "order_no",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "permitPosting",
+            "columnName": "permit_posting",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "theme",
+            "columnName": "theme",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "-1"
+          },
+          {
+            "fieldPath": "preferredAccountsFilter",
+            "columnName": "preferred_accounts_filter",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "futureDates",
+            "columnName": "future_dates",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "apiVersion",
+            "columnName": "api_version",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "showCommodityByDefault",
+            "columnName": "show_commodity_by_default",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "defaultCommodity",
+            "columnName": "default_commodity",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "showCommentsByDefault",
+            "columnName": "show_comments_by_default",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "detectedVersionPre_1_19",
+            "columnName": "detected_version_pre_1_19",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "detectedVersionMajor",
+            "columnName": "detected_version_major",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "detectedVersionMinor",
+            "columnName": "detected_version_minor",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "options",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profile_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `value` TEXT, PRIMARY KEY(`profile_id`, `name`))",
+        "fields": [
+          {
+            "fieldPath": "profileId",
+            "columnName": "profile_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "profile_id",
+            "name"
+          ],
+          "autoGenerate": false
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "account_values",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `account_id` INTEGER NOT NULL, `currency` TEXT NOT NULL DEFAULT '', `value` REAL NOT NULL, `generation` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountId",
+            "columnName": "account_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_account_values",
+            "unique": true,
+            "columnNames": [
+              "account_id",
+              "currency"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_account_values` ON `${TABLE_NAME}` (`account_id`, `currency`)"
+          },
+          {
+            "name": "fk_account_value_acc",
+            "unique": false,
+            "columnNames": [
+              "account_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_account_value_acc` ON `${TABLE_NAME}` (`account_id`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "accounts",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "account_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "transactions",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ledger_id` INTEGER NOT NULL, `profile_id` INTEGER NOT NULL, `data_hash` TEXT NOT NULL, `year` INTEGER NOT NULL, `month` INTEGER NOT NULL, `day` INTEGER NOT NULL, `description` TEXT NOT NULL COLLATE NOCASE, `description_uc` TEXT NOT NULL, `comment` TEXT, `generation` INTEGER NOT NULL, FOREIGN KEY(`profile_id`) REFERENCES `profiles`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "ledgerId",
+            "columnName": "ledger_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "profileId",
+            "columnName": "profile_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "dataHash",
+            "columnName": "data_hash",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "year",
+            "columnName": "year",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "month",
+            "columnName": "month",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "day",
+            "columnName": "day",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "description_uc",
+            "columnName": "description_uc",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "comment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_transactions_ledger_id",
+            "unique": true,
+            "columnNames": [
+              "profile_id",
+              "ledger_id"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_transactions_ledger_id` ON `${TABLE_NAME}` (`profile_id`, `ledger_id`)"
+          },
+          {
+            "name": "idx_transaction_description",
+            "unique": false,
+            "columnNames": [
+              "description"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `idx_transaction_description` ON `${TABLE_NAME}` (`description`)"
+          },
+          {
+            "name": "fk_transaction_profile",
+            "unique": false,
+            "columnNames": [
+              "profile_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_transaction_profile` ON `${TABLE_NAME}` (`profile_id`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "profiles",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "profile_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "transaction_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `transaction_id` INTEGER NOT NULL, `order_no` INTEGER NOT NULL, `account_name` TEXT NOT NULL, `currency` TEXT NOT NULL DEFAULT '', `amount` REAL NOT NULL, `comment` TEXT, `generation` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`transaction_id`) REFERENCES `transactions`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "transactionId",
+            "columnName": "transaction_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "orderNo",
+            "columnName": "order_no",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "account_name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "comment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "fk_trans_acc_trans",
+            "unique": false,
+            "columnNames": [
+              "transaction_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_trans_acc_trans` ON `${TABLE_NAME}` (`transaction_id`)"
+          },
+          {
+            "name": "un_transaction_accounts",
+            "unique": true,
+            "columnNames": [
+              "transaction_id",
+              "order_no"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_transaction_accounts` ON `${TABLE_NAME}` (`transaction_id`, `order_no`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "transactions",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "transaction_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      }
+    ],
+    "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, '0549e893bdbb2c7eb5666c3ee81091d6')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/app/schemas/net.ktnx.mobileledger.db.DB/62.json b/app/schemas/net.ktnx.mobileledger.db.DB/62.json
new file mode 100644 (file)
index 0000000..eb64dfb
--- /dev/null
@@ -0,0 +1,843 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 62,
+    "identityHash": "69591403d82a378a35d1f22e7e8f637f",
+    "entities": [
+      {
+        "tableName": "templates",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `regular_expression` TEXT NOT NULL, `test_text` TEXT, `transaction_description` TEXT, `transaction_description_match_group` INTEGER, `transaction_comment` TEXT, `transaction_comment_match_group` INTEGER, `date_year` INTEGER, `date_year_match_group` INTEGER, `date_month` INTEGER, `date_month_match_group` INTEGER, `date_day` INTEGER, `date_day_match_group` INTEGER, `is_fallback` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "regularExpression",
+            "columnName": "regular_expression",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "testText",
+            "columnName": "test_text",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescription",
+            "columnName": "transaction_description",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescriptionMatchGroup",
+            "columnName": "transaction_description_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionComment",
+            "columnName": "transaction_comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionCommentMatchGroup",
+            "columnName": "transaction_comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYear",
+            "columnName": "date_year",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYearMatchGroup",
+            "columnName": "date_year_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonth",
+            "columnName": "date_month",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonthMatchGroup",
+            "columnName": "date_month_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDay",
+            "columnName": "date_day",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDayMatchGroup",
+            "columnName": "date_day_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "isFallback",
+            "columnName": "is_fallback",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "template_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `template_id` INTEGER NOT NULL, `acc` TEXT, `position` INTEGER NOT NULL, `acc_match_group` INTEGER, `currency` INTEGER, `currency_match_group` INTEGER, `amount` REAL, `amount_match_group` INTEGER, `comment` TEXT, `comment_match_group` INTEGER, `negate_amount` INTEGER, FOREIGN KEY(`template_id`) REFERENCES `templates`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE , FOREIGN KEY(`currency`) REFERENCES `currencies`(`id`) ON UPDATE RESTRICT ON DELETE RESTRICT )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "templateId",
+            "columnName": "template_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "acc",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountNameMatchGroup",
+            "columnName": "acc_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currencyMatchGroup",
+            "columnName": "currency_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amountMatchGroup",
+            "columnName": "amount_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountComment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountCommentMatchGroup",
+            "columnName": "comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "negateAmount",
+            "columnName": "negate_amount",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "fk_template_accounts_template",
+            "unique": false,
+            "columnNames": [
+              "template_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_template_accounts_template` ON `${TABLE_NAME}` (`template_id`)"
+          },
+          {
+            "name": "fk_template_accounts_currency",
+            "unique": false,
+            "columnNames": [
+              "currency"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_template_accounts_currency` ON `${TABLE_NAME}` (`currency`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "templates",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "template_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          },
+          {
+            "table": "currencies",
+            "onDelete": "RESTRICT",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "currency"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "currencies",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `position` TEXT NOT NULL, `has_gap` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "hasGap",
+            "columnName": "has_gap",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "currency_name_idx",
+            "unique": true,
+            "columnNames": [
+              "name"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `currency_name_idx` ON `${TABLE_NAME}` (`name`)"
+          }
+        ],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `profile_id` INTEGER NOT NULL, `level` INTEGER NOT NULL, `name` TEXT NOT NULL, `name_upper` TEXT NOT NULL, `parent_name` TEXT, `expanded` INTEGER NOT NULL DEFAULT 1, `amounts_expanded` INTEGER NOT NULL DEFAULT 0, `generation` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`profile_id`) REFERENCES `profiles`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "profileId",
+            "columnName": "profile_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "level",
+            "columnName": "level",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "nameUpper",
+            "columnName": "name_upper",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "parentName",
+            "columnName": "parent_name",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "expanded",
+            "columnName": "expanded",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "amountsExpanded",
+            "columnName": "amounts_expanded",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_account_name",
+            "unique": true,
+            "columnNames": [
+              "profile_id",
+              "name"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_account_name` ON `${TABLE_NAME}` (`profile_id`, `name`)"
+          },
+          {
+            "name": "fk_account_profile",
+            "unique": false,
+            "columnNames": [
+              "profile_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_account_profile` ON `${TABLE_NAME}` (`profile_id`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "profiles",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "profile_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "profiles",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `deprecated_uuid` TEXT, `url` TEXT NOT NULL, `use_authentication` INTEGER NOT NULL, `auth_user` TEXT, `auth_password` TEXT, `order_no` INTEGER NOT NULL, `permit_posting` INTEGER NOT NULL, `theme` INTEGER NOT NULL DEFAULT -1, `preferred_accounts_filter` TEXT, `future_dates` INTEGER NOT NULL, `api_version` INTEGER NOT NULL, `show_commodity_by_default` INTEGER NOT NULL, `default_commodity` TEXT, `show_comments_by_default` INTEGER NOT NULL DEFAULT 1, `detected_version_pre_1_19` INTEGER NOT NULL, `detected_version_major` INTEGER NOT NULL, `detected_version_minor` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "deprecatedUUID",
+            "columnName": "deprecated_uuid",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "useAuthentication",
+            "columnName": "use_authentication",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "authUser",
+            "columnName": "auth_user",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "authPassword",
+            "columnName": "auth_password",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "orderNo",
+            "columnName": "order_no",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "permitPosting",
+            "columnName": "permit_posting",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "theme",
+            "columnName": "theme",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "-1"
+          },
+          {
+            "fieldPath": "preferredAccountsFilter",
+            "columnName": "preferred_accounts_filter",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "futureDates",
+            "columnName": "future_dates",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "apiVersion",
+            "columnName": "api_version",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "showCommodityByDefault",
+            "columnName": "show_commodity_by_default",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "defaultCommodity",
+            "columnName": "default_commodity",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "showCommentsByDefault",
+            "columnName": "show_comments_by_default",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "detectedVersionPre_1_19",
+            "columnName": "detected_version_pre_1_19",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "detectedVersionMajor",
+            "columnName": "detected_version_major",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "detectedVersionMinor",
+            "columnName": "detected_version_minor",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "options",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profile_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `value` TEXT, PRIMARY KEY(`profile_id`, `name`))",
+        "fields": [
+          {
+            "fieldPath": "profileId",
+            "columnName": "profile_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "profile_id",
+            "name"
+          ],
+          "autoGenerate": false
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "account_values",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `account_id` INTEGER NOT NULL, `currency` TEXT NOT NULL DEFAULT '', `value` REAL NOT NULL, `generation` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountId",
+            "columnName": "account_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_account_values",
+            "unique": true,
+            "columnNames": [
+              "account_id",
+              "currency"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_account_values` ON `${TABLE_NAME}` (`account_id`, `currency`)"
+          },
+          {
+            "name": "fk_account_value_acc",
+            "unique": false,
+            "columnNames": [
+              "account_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_account_value_acc` ON `${TABLE_NAME}` (`account_id`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "accounts",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "account_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "transactions",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ledger_id` INTEGER NOT NULL, `profile_id` INTEGER NOT NULL, `data_hash` TEXT NOT NULL, `year` INTEGER NOT NULL, `month` INTEGER NOT NULL, `day` INTEGER NOT NULL, `description` TEXT NOT NULL COLLATE NOCASE, `description_uc` TEXT NOT NULL, `comment` TEXT, `generation` INTEGER NOT NULL, FOREIGN KEY(`profile_id`) REFERENCES `profiles`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "ledgerId",
+            "columnName": "ledger_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "profileId",
+            "columnName": "profile_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "dataHash",
+            "columnName": "data_hash",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "year",
+            "columnName": "year",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "month",
+            "columnName": "month",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "day",
+            "columnName": "day",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "descriptionUpper",
+            "columnName": "description_uc",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "comment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_transactions_ledger_id",
+            "unique": true,
+            "columnNames": [
+              "profile_id",
+              "ledger_id"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_transactions_ledger_id` ON `${TABLE_NAME}` (`profile_id`, `ledger_id`)"
+          },
+          {
+            "name": "idx_transaction_description",
+            "unique": false,
+            "columnNames": [
+              "description"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `idx_transaction_description` ON `${TABLE_NAME}` (`description`)"
+          },
+          {
+            "name": "fk_transaction_profile",
+            "unique": false,
+            "columnNames": [
+              "profile_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_transaction_profile` ON `${TABLE_NAME}` (`profile_id`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "profiles",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "profile_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "transaction_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `transaction_id` INTEGER NOT NULL, `order_no` INTEGER NOT NULL, `account_name` TEXT NOT NULL, `currency` TEXT NOT NULL DEFAULT '', `amount` REAL NOT NULL, `comment` TEXT, `generation` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`transaction_id`) REFERENCES `transactions`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "transactionId",
+            "columnName": "transaction_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "orderNo",
+            "columnName": "order_no",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "account_name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "comment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "fk_trans_acc_trans",
+            "unique": false,
+            "columnNames": [
+              "transaction_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_trans_acc_trans` ON `${TABLE_NAME}` (`transaction_id`)"
+          },
+          {
+            "name": "un_transaction_accounts",
+            "unique": true,
+            "columnNames": [
+              "transaction_id",
+              "order_no"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_transaction_accounts` ON `${TABLE_NAME}` (`transaction_id`, `order_no`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "transactions",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "transaction_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      }
+    ],
+    "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, '69591403d82a378a35d1f22e7e8f637f')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/app/schemas/net.ktnx.mobileledger.db.DB/63.json b/app/schemas/net.ktnx.mobileledger.db.DB/63.json
new file mode 100644 (file)
index 0000000..f672f70
--- /dev/null
@@ -0,0 +1,867 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 63,
+    "identityHash": "3a9ba5043c6e9109219046e1e29e50e1",
+    "entities": [
+      {
+        "tableName": "templates",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `uuid` TEXT NOT NULL, `regular_expression` TEXT NOT NULL, `test_text` TEXT, `transaction_description` TEXT, `transaction_description_match_group` INTEGER, `transaction_comment` TEXT, `transaction_comment_match_group` INTEGER, `date_year` INTEGER, `date_year_match_group` INTEGER, `date_month` INTEGER, `date_month_match_group` INTEGER, `date_day` INTEGER, `date_day_match_group` INTEGER, `is_fallback` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "uuid",
+            "columnName": "uuid",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "regularExpression",
+            "columnName": "regular_expression",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "testText",
+            "columnName": "test_text",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescription",
+            "columnName": "transaction_description",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescriptionMatchGroup",
+            "columnName": "transaction_description_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionComment",
+            "columnName": "transaction_comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionCommentMatchGroup",
+            "columnName": "transaction_comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYear",
+            "columnName": "date_year",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYearMatchGroup",
+            "columnName": "date_year_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonth",
+            "columnName": "date_month",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonthMatchGroup",
+            "columnName": "date_month_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDay",
+            "columnName": "date_day",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDayMatchGroup",
+            "columnName": "date_day_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "isFallback",
+            "columnName": "is_fallback",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "templates_uuid_idx",
+            "unique": true,
+            "columnNames": [
+              "uuid"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `templates_uuid_idx` ON `${TABLE_NAME}` (`uuid`)"
+          }
+        ],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "template_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `template_id` INTEGER NOT NULL, `acc` TEXT, `position` INTEGER NOT NULL, `acc_match_group` INTEGER, `currency` INTEGER, `currency_match_group` INTEGER, `amount` REAL, `amount_match_group` INTEGER, `comment` TEXT, `comment_match_group` INTEGER, `negate_amount` INTEGER, FOREIGN KEY(`template_id`) REFERENCES `templates`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE , FOREIGN KEY(`currency`) REFERENCES `currencies`(`id`) ON UPDATE RESTRICT ON DELETE RESTRICT )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "templateId",
+            "columnName": "template_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "acc",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountNameMatchGroup",
+            "columnName": "acc_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currencyMatchGroup",
+            "columnName": "currency_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amountMatchGroup",
+            "columnName": "amount_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountComment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountCommentMatchGroup",
+            "columnName": "comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "negateAmount",
+            "columnName": "negate_amount",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "fk_template_accounts_template",
+            "unique": false,
+            "columnNames": [
+              "template_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_template_accounts_template` ON `${TABLE_NAME}` (`template_id`)"
+          },
+          {
+            "name": "fk_template_accounts_currency",
+            "unique": false,
+            "columnNames": [
+              "currency"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_template_accounts_currency` ON `${TABLE_NAME}` (`currency`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "templates",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "template_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          },
+          {
+            "table": "currencies",
+            "onDelete": "RESTRICT",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "currency"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "currencies",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `position` TEXT NOT NULL, `has_gap` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "hasGap",
+            "columnName": "has_gap",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "currency_name_idx",
+            "unique": true,
+            "columnNames": [
+              "name"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `currency_name_idx` ON `${TABLE_NAME}` (`name`)"
+          }
+        ],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `profile_id` INTEGER NOT NULL, `level` INTEGER NOT NULL, `name` TEXT NOT NULL, `name_upper` TEXT NOT NULL, `parent_name` TEXT, `expanded` INTEGER NOT NULL DEFAULT 1, `amounts_expanded` INTEGER NOT NULL DEFAULT 0, `generation` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`profile_id`) REFERENCES `profiles`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "profileId",
+            "columnName": "profile_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "level",
+            "columnName": "level",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "nameUpper",
+            "columnName": "name_upper",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "parentName",
+            "columnName": "parent_name",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "expanded",
+            "columnName": "expanded",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "amountsExpanded",
+            "columnName": "amounts_expanded",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_account_name",
+            "unique": true,
+            "columnNames": [
+              "profile_id",
+              "name"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_account_name` ON `${TABLE_NAME}` (`profile_id`, `name`)"
+          },
+          {
+            "name": "fk_account_profile",
+            "unique": false,
+            "columnNames": [
+              "profile_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_account_profile` ON `${TABLE_NAME}` (`profile_id`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "profiles",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "profile_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "profiles",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `uuid` TEXT, `url` TEXT NOT NULL, `use_authentication` INTEGER NOT NULL, `auth_user` TEXT, `auth_password` TEXT, `order_no` INTEGER NOT NULL, `permit_posting` INTEGER NOT NULL, `theme` INTEGER NOT NULL DEFAULT -1, `preferred_accounts_filter` TEXT, `future_dates` INTEGER NOT NULL, `api_version` INTEGER NOT NULL, `show_commodity_by_default` INTEGER NOT NULL, `default_commodity` TEXT, `show_comments_by_default` INTEGER NOT NULL DEFAULT 1, `detected_version_pre_1_19` INTEGER NOT NULL, `detected_version_major` INTEGER NOT NULL, `detected_version_minor` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "uuid",
+            "columnName": "uuid",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "useAuthentication",
+            "columnName": "use_authentication",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "authUser",
+            "columnName": "auth_user",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "authPassword",
+            "columnName": "auth_password",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "orderNo",
+            "columnName": "order_no",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "permitPosting",
+            "columnName": "permit_posting",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "theme",
+            "columnName": "theme",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "-1"
+          },
+          {
+            "fieldPath": "preferredAccountsFilter",
+            "columnName": "preferred_accounts_filter",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "futureDates",
+            "columnName": "future_dates",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "apiVersion",
+            "columnName": "api_version",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "showCommodityByDefault",
+            "columnName": "show_commodity_by_default",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "defaultCommodity",
+            "columnName": "default_commodity",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "showCommentsByDefault",
+            "columnName": "show_comments_by_default",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "detectedVersionPre_1_19",
+            "columnName": "detected_version_pre_1_19",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "detectedVersionMajor",
+            "columnName": "detected_version_major",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "detectedVersionMinor",
+            "columnName": "detected_version_minor",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "profiles_uuid_idx",
+            "unique": true,
+            "columnNames": [
+              "uuid"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `profiles_uuid_idx` ON `${TABLE_NAME}` (`uuid`)"
+          }
+        ],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "options",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profile_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `value` TEXT, PRIMARY KEY(`profile_id`, `name`))",
+        "fields": [
+          {
+            "fieldPath": "profileId",
+            "columnName": "profile_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "profile_id",
+            "name"
+          ],
+          "autoGenerate": false
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "account_values",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `account_id` INTEGER NOT NULL, `currency` TEXT NOT NULL DEFAULT '', `value` REAL NOT NULL, `generation` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountId",
+            "columnName": "account_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_account_values",
+            "unique": true,
+            "columnNames": [
+              "account_id",
+              "currency"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_account_values` ON `${TABLE_NAME}` (`account_id`, `currency`)"
+          },
+          {
+            "name": "fk_account_value_acc",
+            "unique": false,
+            "columnNames": [
+              "account_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_account_value_acc` ON `${TABLE_NAME}` (`account_id`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "accounts",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "account_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "transactions",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ledger_id` INTEGER NOT NULL, `profile_id` INTEGER NOT NULL, `data_hash` TEXT NOT NULL, `year` INTEGER NOT NULL, `month` INTEGER NOT NULL, `day` INTEGER NOT NULL, `description` TEXT NOT NULL COLLATE NOCASE, `description_uc` TEXT NOT NULL, `comment` TEXT, `generation` INTEGER NOT NULL, FOREIGN KEY(`profile_id`) REFERENCES `profiles`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "ledgerId",
+            "columnName": "ledger_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "profileId",
+            "columnName": "profile_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "dataHash",
+            "columnName": "data_hash",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "year",
+            "columnName": "year",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "month",
+            "columnName": "month",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "day",
+            "columnName": "day",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "descriptionUpper",
+            "columnName": "description_uc",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "comment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_transactions_ledger_id",
+            "unique": true,
+            "columnNames": [
+              "profile_id",
+              "ledger_id"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_transactions_ledger_id` ON `${TABLE_NAME}` (`profile_id`, `ledger_id`)"
+          },
+          {
+            "name": "idx_transaction_description",
+            "unique": false,
+            "columnNames": [
+              "description"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `idx_transaction_description` ON `${TABLE_NAME}` (`description`)"
+          },
+          {
+            "name": "fk_transaction_profile",
+            "unique": false,
+            "columnNames": [
+              "profile_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_transaction_profile` ON `${TABLE_NAME}` (`profile_id`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "profiles",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "profile_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "transaction_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `transaction_id` INTEGER NOT NULL, `order_no` INTEGER NOT NULL, `account_name` TEXT NOT NULL, `currency` TEXT NOT NULL DEFAULT '', `amount` REAL NOT NULL, `comment` TEXT, `generation` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`transaction_id`) REFERENCES `transactions`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "transactionId",
+            "columnName": "transaction_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "orderNo",
+            "columnName": "order_no",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "account_name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "comment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "fk_trans_acc_trans",
+            "unique": false,
+            "columnNames": [
+              "transaction_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_trans_acc_trans` ON `${TABLE_NAME}` (`transaction_id`)"
+          },
+          {
+            "name": "un_transaction_accounts",
+            "unique": true,
+            "columnNames": [
+              "transaction_id",
+              "order_no"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_transaction_accounts` ON `${TABLE_NAME}` (`transaction_id`, `order_no`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "transactions",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "transaction_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      }
+    ],
+    "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, '3a9ba5043c6e9109219046e1e29e50e1')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/app/schemas/net.ktnx.mobileledger.db.DB/64.json b/app/schemas/net.ktnx.mobileledger.db.DB/64.json
new file mode 100644 (file)
index 0000000..2155828
--- /dev/null
@@ -0,0 +1,867 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 64,
+    "identityHash": "0739ea866a6aebb4217f68a7fcda5bc6",
+    "entities": [
+      {
+        "tableName": "templates",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `uuid` TEXT NOT NULL, `regular_expression` TEXT NOT NULL, `test_text` TEXT, `transaction_description` TEXT, `transaction_description_match_group` INTEGER, `transaction_comment` TEXT, `transaction_comment_match_group` INTEGER, `date_year` INTEGER, `date_year_match_group` INTEGER, `date_month` INTEGER, `date_month_match_group` INTEGER, `date_day` INTEGER, `date_day_match_group` INTEGER, `is_fallback` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "uuid",
+            "columnName": "uuid",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "regularExpression",
+            "columnName": "regular_expression",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "testText",
+            "columnName": "test_text",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescription",
+            "columnName": "transaction_description",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescriptionMatchGroup",
+            "columnName": "transaction_description_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionComment",
+            "columnName": "transaction_comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionCommentMatchGroup",
+            "columnName": "transaction_comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYear",
+            "columnName": "date_year",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYearMatchGroup",
+            "columnName": "date_year_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonth",
+            "columnName": "date_month",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonthMatchGroup",
+            "columnName": "date_month_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDay",
+            "columnName": "date_day",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDayMatchGroup",
+            "columnName": "date_day_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "isFallback",
+            "columnName": "is_fallback",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "templates_uuid_idx",
+            "unique": true,
+            "columnNames": [
+              "uuid"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `templates_uuid_idx` ON `${TABLE_NAME}` (`uuid`)"
+          }
+        ],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "template_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `template_id` INTEGER NOT NULL, `acc` TEXT, `position` INTEGER NOT NULL, `acc_match_group` INTEGER, `currency` INTEGER, `currency_match_group` INTEGER, `amount` REAL, `amount_match_group` INTEGER, `comment` TEXT, `comment_match_group` INTEGER, `negate_amount` INTEGER, FOREIGN KEY(`template_id`) REFERENCES `templates`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE , FOREIGN KEY(`currency`) REFERENCES `currencies`(`id`) ON UPDATE RESTRICT ON DELETE RESTRICT )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "templateId",
+            "columnName": "template_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "acc",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountNameMatchGroup",
+            "columnName": "acc_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currencyMatchGroup",
+            "columnName": "currency_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amountMatchGroup",
+            "columnName": "amount_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountComment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountCommentMatchGroup",
+            "columnName": "comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "negateAmount",
+            "columnName": "negate_amount",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "fk_template_accounts_template",
+            "unique": false,
+            "columnNames": [
+              "template_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_template_accounts_template` ON `${TABLE_NAME}` (`template_id`)"
+          },
+          {
+            "name": "fk_template_accounts_currency",
+            "unique": false,
+            "columnNames": [
+              "currency"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_template_accounts_currency` ON `${TABLE_NAME}` (`currency`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "templates",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "template_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          },
+          {
+            "table": "currencies",
+            "onDelete": "RESTRICT",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "currency"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "currencies",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `position` TEXT NOT NULL, `has_gap` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "hasGap",
+            "columnName": "has_gap",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "currency_name_idx",
+            "unique": true,
+            "columnNames": [
+              "name"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `currency_name_idx` ON `${TABLE_NAME}` (`name`)"
+          }
+        ],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `profile_id` INTEGER NOT NULL, `level` INTEGER NOT NULL, `name` TEXT NOT NULL, `name_upper` TEXT NOT NULL, `parent_name` TEXT, `expanded` INTEGER NOT NULL DEFAULT 1, `amounts_expanded` INTEGER NOT NULL DEFAULT 0, `generation` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`profile_id`) REFERENCES `profiles`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "profileId",
+            "columnName": "profile_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "level",
+            "columnName": "level",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "nameUpper",
+            "columnName": "name_upper",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "parentName",
+            "columnName": "parent_name",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "expanded",
+            "columnName": "expanded",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "amountsExpanded",
+            "columnName": "amounts_expanded",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_account_name",
+            "unique": true,
+            "columnNames": [
+              "profile_id",
+              "name"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_account_name` ON `${TABLE_NAME}` (`profile_id`, `name`)"
+          },
+          {
+            "name": "fk_account_profile",
+            "unique": false,
+            "columnNames": [
+              "profile_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_account_profile` ON `${TABLE_NAME}` (`profile_id`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "profiles",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "profile_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "profiles",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `uuid` TEXT NOT NULL, `url` TEXT NOT NULL, `use_authentication` INTEGER NOT NULL, `auth_user` TEXT, `auth_password` TEXT, `order_no` INTEGER NOT NULL, `permit_posting` INTEGER NOT NULL, `theme` INTEGER NOT NULL DEFAULT -1, `preferred_accounts_filter` TEXT, `future_dates` INTEGER NOT NULL, `api_version` INTEGER NOT NULL, `show_commodity_by_default` INTEGER NOT NULL, `default_commodity` TEXT, `show_comments_by_default` INTEGER NOT NULL DEFAULT 1, `detected_version_pre_1_19` INTEGER NOT NULL, `detected_version_major` INTEGER NOT NULL, `detected_version_minor` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "uuid",
+            "columnName": "uuid",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "useAuthentication",
+            "columnName": "use_authentication",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "authUser",
+            "columnName": "auth_user",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "authPassword",
+            "columnName": "auth_password",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "orderNo",
+            "columnName": "order_no",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "permitPosting",
+            "columnName": "permit_posting",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "theme",
+            "columnName": "theme",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "-1"
+          },
+          {
+            "fieldPath": "preferredAccountsFilter",
+            "columnName": "preferred_accounts_filter",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "futureDates",
+            "columnName": "future_dates",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "apiVersion",
+            "columnName": "api_version",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "showCommodityByDefault",
+            "columnName": "show_commodity_by_default",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "defaultCommodity",
+            "columnName": "default_commodity",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "showCommentsByDefault",
+            "columnName": "show_comments_by_default",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "detectedVersionPre_1_19",
+            "columnName": "detected_version_pre_1_19",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "detectedVersionMajor",
+            "columnName": "detected_version_major",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "detectedVersionMinor",
+            "columnName": "detected_version_minor",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "profiles_uuid_idx",
+            "unique": true,
+            "columnNames": [
+              "uuid"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `profiles_uuid_idx` ON `${TABLE_NAME}` (`uuid`)"
+          }
+        ],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "options",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profile_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `value` TEXT, PRIMARY KEY(`profile_id`, `name`))",
+        "fields": [
+          {
+            "fieldPath": "profileId",
+            "columnName": "profile_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "profile_id",
+            "name"
+          ],
+          "autoGenerate": false
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "account_values",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `account_id` INTEGER NOT NULL, `currency` TEXT NOT NULL DEFAULT '', `value` REAL NOT NULL, `generation` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountId",
+            "columnName": "account_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_account_values",
+            "unique": true,
+            "columnNames": [
+              "account_id",
+              "currency"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_account_values` ON `${TABLE_NAME}` (`account_id`, `currency`)"
+          },
+          {
+            "name": "fk_account_value_acc",
+            "unique": false,
+            "columnNames": [
+              "account_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_account_value_acc` ON `${TABLE_NAME}` (`account_id`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "accounts",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "account_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "transactions",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ledger_id` INTEGER NOT NULL, `profile_id` INTEGER NOT NULL, `data_hash` TEXT NOT NULL, `year` INTEGER NOT NULL, `month` INTEGER NOT NULL, `day` INTEGER NOT NULL, `description` TEXT NOT NULL COLLATE NOCASE, `description_uc` TEXT NOT NULL, `comment` TEXT, `generation` INTEGER NOT NULL, FOREIGN KEY(`profile_id`) REFERENCES `profiles`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "ledgerId",
+            "columnName": "ledger_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "profileId",
+            "columnName": "profile_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "dataHash",
+            "columnName": "data_hash",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "year",
+            "columnName": "year",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "month",
+            "columnName": "month",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "day",
+            "columnName": "day",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "descriptionUpper",
+            "columnName": "description_uc",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "comment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_transactions_ledger_id",
+            "unique": true,
+            "columnNames": [
+              "profile_id",
+              "ledger_id"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_transactions_ledger_id` ON `${TABLE_NAME}` (`profile_id`, `ledger_id`)"
+          },
+          {
+            "name": "idx_transaction_description",
+            "unique": false,
+            "columnNames": [
+              "description"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `idx_transaction_description` ON `${TABLE_NAME}` (`description`)"
+          },
+          {
+            "name": "fk_transaction_profile",
+            "unique": false,
+            "columnNames": [
+              "profile_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_transaction_profile` ON `${TABLE_NAME}` (`profile_id`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "profiles",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "profile_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "transaction_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `transaction_id` INTEGER NOT NULL, `order_no` INTEGER NOT NULL, `account_name` TEXT NOT NULL, `currency` TEXT NOT NULL DEFAULT '', `amount` REAL NOT NULL, `comment` TEXT, `generation` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`transaction_id`) REFERENCES `transactions`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "transactionId",
+            "columnName": "transaction_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "orderNo",
+            "columnName": "order_no",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "account_name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "comment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "fk_trans_acc_trans",
+            "unique": false,
+            "columnNames": [
+              "transaction_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_trans_acc_trans` ON `${TABLE_NAME}` (`transaction_id`)"
+          },
+          {
+            "name": "un_transaction_accounts",
+            "unique": true,
+            "columnNames": [
+              "transaction_id",
+              "order_no"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_transaction_accounts` ON `${TABLE_NAME}` (`transaction_id`, `order_no`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "transactions",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "transaction_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      }
+    ],
+    "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, '0739ea866a6aebb4217f68a7fcda5bc6')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/app/schemas/net.ktnx.mobileledger.db.DB/65.json b/app/schemas/net.ktnx.mobileledger.db.DB/65.json
new file mode 100644 (file)
index 0000000..9b4fd86
--- /dev/null
@@ -0,0 +1,867 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 65,
+    "identityHash": "0739ea866a6aebb4217f68a7fcda5bc6",
+    "entities": [
+      {
+        "tableName": "templates",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `uuid` TEXT NOT NULL, `regular_expression` TEXT NOT NULL, `test_text` TEXT, `transaction_description` TEXT, `transaction_description_match_group` INTEGER, `transaction_comment` TEXT, `transaction_comment_match_group` INTEGER, `date_year` INTEGER, `date_year_match_group` INTEGER, `date_month` INTEGER, `date_month_match_group` INTEGER, `date_day` INTEGER, `date_day_match_group` INTEGER, `is_fallback` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "uuid",
+            "columnName": "uuid",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "regularExpression",
+            "columnName": "regular_expression",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "testText",
+            "columnName": "test_text",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescription",
+            "columnName": "transaction_description",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescriptionMatchGroup",
+            "columnName": "transaction_description_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionComment",
+            "columnName": "transaction_comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionCommentMatchGroup",
+            "columnName": "transaction_comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYear",
+            "columnName": "date_year",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYearMatchGroup",
+            "columnName": "date_year_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonth",
+            "columnName": "date_month",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonthMatchGroup",
+            "columnName": "date_month_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDay",
+            "columnName": "date_day",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDayMatchGroup",
+            "columnName": "date_day_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "isFallback",
+            "columnName": "is_fallback",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "templates_uuid_idx",
+            "unique": true,
+            "columnNames": [
+              "uuid"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `templates_uuid_idx` ON `${TABLE_NAME}` (`uuid`)"
+          }
+        ],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "template_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `template_id` INTEGER NOT NULL, `acc` TEXT, `position` INTEGER NOT NULL, `acc_match_group` INTEGER, `currency` INTEGER, `currency_match_group` INTEGER, `amount` REAL, `amount_match_group` INTEGER, `comment` TEXT, `comment_match_group` INTEGER, `negate_amount` INTEGER, FOREIGN KEY(`template_id`) REFERENCES `templates`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE , FOREIGN KEY(`currency`) REFERENCES `currencies`(`id`) ON UPDATE RESTRICT ON DELETE RESTRICT )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "templateId",
+            "columnName": "template_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "acc",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountNameMatchGroup",
+            "columnName": "acc_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currencyMatchGroup",
+            "columnName": "currency_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amountMatchGroup",
+            "columnName": "amount_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountComment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountCommentMatchGroup",
+            "columnName": "comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "negateAmount",
+            "columnName": "negate_amount",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "fk_template_accounts_template",
+            "unique": false,
+            "columnNames": [
+              "template_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_template_accounts_template` ON `${TABLE_NAME}` (`template_id`)"
+          },
+          {
+            "name": "fk_template_accounts_currency",
+            "unique": false,
+            "columnNames": [
+              "currency"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_template_accounts_currency` ON `${TABLE_NAME}` (`currency`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "templates",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "template_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          },
+          {
+            "table": "currencies",
+            "onDelete": "RESTRICT",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "currency"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "currencies",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `position` TEXT NOT NULL, `has_gap` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "hasGap",
+            "columnName": "has_gap",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "currency_name_idx",
+            "unique": true,
+            "columnNames": [
+              "name"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `currency_name_idx` ON `${TABLE_NAME}` (`name`)"
+          }
+        ],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `profile_id` INTEGER NOT NULL, `level` INTEGER NOT NULL, `name` TEXT NOT NULL, `name_upper` TEXT NOT NULL, `parent_name` TEXT, `expanded` INTEGER NOT NULL DEFAULT 1, `amounts_expanded` INTEGER NOT NULL DEFAULT 0, `generation` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`profile_id`) REFERENCES `profiles`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "profileId",
+            "columnName": "profile_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "level",
+            "columnName": "level",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "nameUpper",
+            "columnName": "name_upper",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "parentName",
+            "columnName": "parent_name",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "expanded",
+            "columnName": "expanded",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "amountsExpanded",
+            "columnName": "amounts_expanded",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_account_name",
+            "unique": true,
+            "columnNames": [
+              "profile_id",
+              "name"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_account_name` ON `${TABLE_NAME}` (`profile_id`, `name`)"
+          },
+          {
+            "name": "fk_account_profile",
+            "unique": false,
+            "columnNames": [
+              "profile_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_account_profile` ON `${TABLE_NAME}` (`profile_id`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "profiles",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "profile_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "profiles",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `uuid` TEXT NOT NULL, `url` TEXT NOT NULL, `use_authentication` INTEGER NOT NULL, `auth_user` TEXT, `auth_password` TEXT, `order_no` INTEGER NOT NULL, `permit_posting` INTEGER NOT NULL, `theme` INTEGER NOT NULL DEFAULT -1, `preferred_accounts_filter` TEXT, `future_dates` INTEGER NOT NULL, `api_version` INTEGER NOT NULL, `show_commodity_by_default` INTEGER NOT NULL, `default_commodity` TEXT, `show_comments_by_default` INTEGER NOT NULL DEFAULT 1, `detected_version_pre_1_19` INTEGER NOT NULL, `detected_version_major` INTEGER NOT NULL, `detected_version_minor` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "uuid",
+            "columnName": "uuid",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "useAuthentication",
+            "columnName": "use_authentication",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "authUser",
+            "columnName": "auth_user",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "authPassword",
+            "columnName": "auth_password",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "orderNo",
+            "columnName": "order_no",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "permitPosting",
+            "columnName": "permit_posting",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "theme",
+            "columnName": "theme",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "-1"
+          },
+          {
+            "fieldPath": "preferredAccountsFilter",
+            "columnName": "preferred_accounts_filter",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "futureDates",
+            "columnName": "future_dates",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "apiVersion",
+            "columnName": "api_version",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "showCommodityByDefault",
+            "columnName": "show_commodity_by_default",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "defaultCommodity",
+            "columnName": "default_commodity",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "showCommentsByDefault",
+            "columnName": "show_comments_by_default",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "detectedVersionPre_1_19",
+            "columnName": "detected_version_pre_1_19",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "detectedVersionMajor",
+            "columnName": "detected_version_major",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "detectedVersionMinor",
+            "columnName": "detected_version_minor",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "profiles_uuid_idx",
+            "unique": true,
+            "columnNames": [
+              "uuid"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `profiles_uuid_idx` ON `${TABLE_NAME}` (`uuid`)"
+          }
+        ],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "options",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profile_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `value` TEXT, PRIMARY KEY(`profile_id`, `name`))",
+        "fields": [
+          {
+            "fieldPath": "profileId",
+            "columnName": "profile_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "profile_id",
+            "name"
+          ],
+          "autoGenerate": false
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "account_values",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `account_id` INTEGER NOT NULL, `currency` TEXT NOT NULL DEFAULT '', `value` REAL NOT NULL, `generation` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountId",
+            "columnName": "account_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_account_values",
+            "unique": true,
+            "columnNames": [
+              "account_id",
+              "currency"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_account_values` ON `${TABLE_NAME}` (`account_id`, `currency`)"
+          },
+          {
+            "name": "fk_account_value_acc",
+            "unique": false,
+            "columnNames": [
+              "account_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_account_value_acc` ON `${TABLE_NAME}` (`account_id`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "accounts",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "account_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "transactions",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ledger_id` INTEGER NOT NULL, `profile_id` INTEGER NOT NULL, `data_hash` TEXT NOT NULL, `year` INTEGER NOT NULL, `month` INTEGER NOT NULL, `day` INTEGER NOT NULL, `description` TEXT NOT NULL COLLATE NOCASE, `description_uc` TEXT NOT NULL, `comment` TEXT, `generation` INTEGER NOT NULL, FOREIGN KEY(`profile_id`) REFERENCES `profiles`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "ledgerId",
+            "columnName": "ledger_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "profileId",
+            "columnName": "profile_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "dataHash",
+            "columnName": "data_hash",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "year",
+            "columnName": "year",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "month",
+            "columnName": "month",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "day",
+            "columnName": "day",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "descriptionUpper",
+            "columnName": "description_uc",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "comment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_transactions_ledger_id",
+            "unique": true,
+            "columnNames": [
+              "profile_id",
+              "ledger_id"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_transactions_ledger_id` ON `${TABLE_NAME}` (`profile_id`, `ledger_id`)"
+          },
+          {
+            "name": "idx_transaction_description",
+            "unique": false,
+            "columnNames": [
+              "description"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `idx_transaction_description` ON `${TABLE_NAME}` (`description`)"
+          },
+          {
+            "name": "fk_transaction_profile",
+            "unique": false,
+            "columnNames": [
+              "profile_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_transaction_profile` ON `${TABLE_NAME}` (`profile_id`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "profiles",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "profile_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "transaction_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `transaction_id` INTEGER NOT NULL, `order_no` INTEGER NOT NULL, `account_name` TEXT NOT NULL, `currency` TEXT NOT NULL DEFAULT '', `amount` REAL NOT NULL, `comment` TEXT, `generation` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`transaction_id`) REFERENCES `transactions`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "transactionId",
+            "columnName": "transaction_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "orderNo",
+            "columnName": "order_no",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "account_name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "comment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "fk_trans_acc_trans",
+            "unique": false,
+            "columnNames": [
+              "transaction_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_trans_acc_trans` ON `${TABLE_NAME}` (`transaction_id`)"
+          },
+          {
+            "name": "un_transaction_accounts",
+            "unique": true,
+            "columnNames": [
+              "transaction_id",
+              "order_no"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_transaction_accounts` ON `${TABLE_NAME}` (`transaction_id`, `order_no`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "transactions",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "transaction_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      }
+    ],
+    "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, '0739ea866a6aebb4217f68a7fcda5bc6')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/app/schemas/net.ktnx.mobileledger.db.DB/66.json b/app/schemas/net.ktnx.mobileledger.db.DB/66.json
new file mode 100644 (file)
index 0000000..13cd5d8
--- /dev/null
@@ -0,0 +1,867 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 66,
+    "identityHash": "0739ea866a6aebb4217f68a7fcda5bc6",
+    "entities": [
+      {
+        "tableName": "templates",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `uuid` TEXT NOT NULL, `regular_expression` TEXT NOT NULL, `test_text` TEXT, `transaction_description` TEXT, `transaction_description_match_group` INTEGER, `transaction_comment` TEXT, `transaction_comment_match_group` INTEGER, `date_year` INTEGER, `date_year_match_group` INTEGER, `date_month` INTEGER, `date_month_match_group` INTEGER, `date_day` INTEGER, `date_day_match_group` INTEGER, `is_fallback` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "uuid",
+            "columnName": "uuid",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "regularExpression",
+            "columnName": "regular_expression",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "testText",
+            "columnName": "test_text",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescription",
+            "columnName": "transaction_description",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionDescriptionMatchGroup",
+            "columnName": "transaction_description_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionComment",
+            "columnName": "transaction_comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "transactionCommentMatchGroup",
+            "columnName": "transaction_comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYear",
+            "columnName": "date_year",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateYearMatchGroup",
+            "columnName": "date_year_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonth",
+            "columnName": "date_month",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateMonthMatchGroup",
+            "columnName": "date_month_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDay",
+            "columnName": "date_day",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "dateDayMatchGroup",
+            "columnName": "date_day_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "isFallback",
+            "columnName": "is_fallback",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "templates_uuid_idx",
+            "unique": true,
+            "columnNames": [
+              "uuid"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `templates_uuid_idx` ON `${TABLE_NAME}` (`uuid`)"
+          }
+        ],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "template_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `template_id` INTEGER NOT NULL, `acc` TEXT, `position` INTEGER NOT NULL, `acc_match_group` INTEGER, `currency` INTEGER, `currency_match_group` INTEGER, `amount` REAL, `amount_match_group` INTEGER, `comment` TEXT, `comment_match_group` INTEGER, `negate_amount` INTEGER, FOREIGN KEY(`template_id`) REFERENCES `templates`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE , FOREIGN KEY(`currency`) REFERENCES `currencies`(`id`) ON UPDATE RESTRICT ON DELETE RESTRICT )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "templateId",
+            "columnName": "template_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "acc",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountNameMatchGroup",
+            "columnName": "acc_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "currencyMatchGroup",
+            "columnName": "currency_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": false
+          },
+          {
+            "fieldPath": "amountMatchGroup",
+            "columnName": "amount_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountComment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountCommentMatchGroup",
+            "columnName": "comment_match_group",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "negateAmount",
+            "columnName": "negate_amount",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "fk_template_accounts_template",
+            "unique": false,
+            "columnNames": [
+              "template_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_template_accounts_template` ON `${TABLE_NAME}` (`template_id`)"
+          },
+          {
+            "name": "fk_template_accounts_currency",
+            "unique": false,
+            "columnNames": [
+              "currency"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_template_accounts_currency` ON `${TABLE_NAME}` (`currency`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "templates",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "template_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          },
+          {
+            "table": "currencies",
+            "onDelete": "RESTRICT",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "currency"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "currencies",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `position` TEXT NOT NULL, `has_gap` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "hasGap",
+            "columnName": "has_gap",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "currency_name_idx",
+            "unique": true,
+            "columnNames": [
+              "name"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `currency_name_idx` ON `${TABLE_NAME}` (`name`)"
+          }
+        ],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `profile_id` INTEGER NOT NULL, `level` INTEGER NOT NULL, `name` TEXT NOT NULL, `name_upper` TEXT NOT NULL, `parent_name` TEXT, `expanded` INTEGER NOT NULL DEFAULT 1, `amounts_expanded` INTEGER NOT NULL DEFAULT 0, `generation` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`profile_id`) REFERENCES `profiles`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "profileId",
+            "columnName": "profile_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "level",
+            "columnName": "level",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "nameUpper",
+            "columnName": "name_upper",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "parentName",
+            "columnName": "parent_name",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "expanded",
+            "columnName": "expanded",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "amountsExpanded",
+            "columnName": "amounts_expanded",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_account_name",
+            "unique": true,
+            "columnNames": [
+              "profile_id",
+              "name"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_account_name` ON `${TABLE_NAME}` (`profile_id`, `name`)"
+          },
+          {
+            "name": "fk_account_profile",
+            "unique": false,
+            "columnNames": [
+              "profile_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_account_profile` ON `${TABLE_NAME}` (`profile_id`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "profiles",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "profile_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "profiles",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `uuid` TEXT NOT NULL, `url` TEXT NOT NULL, `use_authentication` INTEGER NOT NULL, `auth_user` TEXT, `auth_password` TEXT, `order_no` INTEGER NOT NULL, `permit_posting` INTEGER NOT NULL, `theme` INTEGER NOT NULL DEFAULT -1, `preferred_accounts_filter` TEXT, `future_dates` INTEGER NOT NULL, `api_version` INTEGER NOT NULL, `show_commodity_by_default` INTEGER NOT NULL, `default_commodity` TEXT, `show_comments_by_default` INTEGER NOT NULL DEFAULT 1, `detected_version_pre_1_19` INTEGER NOT NULL, `detected_version_major` INTEGER NOT NULL, `detected_version_minor` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "uuid",
+            "columnName": "uuid",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "useAuthentication",
+            "columnName": "use_authentication",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "authUser",
+            "columnName": "auth_user",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "authPassword",
+            "columnName": "auth_password",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "orderNo",
+            "columnName": "order_no",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "permitPosting",
+            "columnName": "permit_posting",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "theme",
+            "columnName": "theme",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "-1"
+          },
+          {
+            "fieldPath": "preferredAccountsFilter",
+            "columnName": "preferred_accounts_filter",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "futureDates",
+            "columnName": "future_dates",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "apiVersion",
+            "columnName": "api_version",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "showCommodityByDefault",
+            "columnName": "show_commodity_by_default",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "defaultCommodity",
+            "columnName": "default_commodity",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "showCommentsByDefault",
+            "columnName": "show_comments_by_default",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "detectedVersionPre_1_19",
+            "columnName": "detected_version_pre_1_19",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "detectedVersionMajor",
+            "columnName": "detected_version_major",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "detectedVersionMinor",
+            "columnName": "detected_version_minor",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "profiles_uuid_idx",
+            "unique": true,
+            "columnNames": [
+              "uuid"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `profiles_uuid_idx` ON `${TABLE_NAME}` (`uuid`)"
+          }
+        ],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "options",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profile_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `value` TEXT, PRIMARY KEY(`profile_id`, `name`))",
+        "fields": [
+          {
+            "fieldPath": "profileId",
+            "columnName": "profile_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "profile_id",
+            "name"
+          ],
+          "autoGenerate": false
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "account_values",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `account_id` INTEGER NOT NULL, `currency` TEXT NOT NULL DEFAULT '', `value` REAL NOT NULL, `generation` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountId",
+            "columnName": "account_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_account_values",
+            "unique": true,
+            "columnNames": [
+              "account_id",
+              "currency"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_account_values` ON `${TABLE_NAME}` (`account_id`, `currency`)"
+          },
+          {
+            "name": "fk_account_value_acc",
+            "unique": false,
+            "columnNames": [
+              "account_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_account_value_acc` ON `${TABLE_NAME}` (`account_id`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "accounts",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "account_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "transactions",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ledger_id` INTEGER NOT NULL, `profile_id` INTEGER NOT NULL, `data_hash` TEXT NOT NULL, `year` INTEGER NOT NULL, `month` INTEGER NOT NULL, `day` INTEGER NOT NULL, `description` TEXT NOT NULL COLLATE NOCASE, `description_uc` TEXT NOT NULL, `comment` TEXT, `generation` INTEGER NOT NULL, FOREIGN KEY(`profile_id`) REFERENCES `profiles`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "ledgerId",
+            "columnName": "ledger_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "profileId",
+            "columnName": "profile_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "dataHash",
+            "columnName": "data_hash",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "year",
+            "columnName": "year",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "month",
+            "columnName": "month",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "day",
+            "columnName": "day",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "descriptionUpper",
+            "columnName": "description_uc",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "comment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "un_transactions_ledger_id",
+            "unique": true,
+            "columnNames": [
+              "profile_id",
+              "ledger_id"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_transactions_ledger_id` ON `${TABLE_NAME}` (`profile_id`, `ledger_id`)"
+          },
+          {
+            "name": "idx_transaction_description",
+            "unique": false,
+            "columnNames": [
+              "description"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `idx_transaction_description` ON `${TABLE_NAME}` (`description`)"
+          },
+          {
+            "name": "fk_transaction_profile",
+            "unique": false,
+            "columnNames": [
+              "profile_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_transaction_profile` ON `${TABLE_NAME}` (`profile_id`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "profiles",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "profile_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "transaction_accounts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `transaction_id` INTEGER NOT NULL, `order_no` INTEGER NOT NULL, `account_name` TEXT NOT NULL, `currency` TEXT NOT NULL DEFAULT '', `amount` REAL NOT NULL, `comment` TEXT, `generation` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`transaction_id`) REFERENCES `transactions`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "transactionId",
+            "columnName": "transaction_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "orderNo",
+            "columnName": "order_no",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "account_name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "currency",
+            "columnName": "currency",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "amount",
+            "columnName": "amount",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "comment",
+            "columnName": "comment",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "generation",
+            "columnName": "generation",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "fk_trans_acc_trans",
+            "unique": false,
+            "columnNames": [
+              "transaction_id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `fk_trans_acc_trans` ON `${TABLE_NAME}` (`transaction_id`)"
+          },
+          {
+            "name": "un_transaction_accounts",
+            "unique": true,
+            "columnNames": [
+              "transaction_id",
+              "order_no"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `un_transaction_accounts` ON `${TABLE_NAME}` (`transaction_id`, `order_no`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "transactions",
+            "onDelete": "CASCADE",
+            "onUpdate": "RESTRICT",
+            "columns": [
+              "transaction_id"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      }
+    ],
+    "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, '0739ea866a6aebb4217f68a7fcda5bc6')"
+    ]
+  }
+}
\ No newline at end of file
index 430b395170ed49438fb988e1fa8fcb615291067d..b17a1ecfcacfaec410eee37f6be145c34c967ac7 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?><!--
-  ~ Copyright © 2020 Damyan Ivanov.
+  ~ Copyright © 2024 Damyan Ivanov.
   ~ This file is part of MoLe.
   ~ MoLe is free software: you can distribute it and/or modify it
   ~ under the term of the GNU General Public License as published by
@@ -15,8 +15,7 @@
   ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
   -->
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:tools="http://schemas.android.com/tools"
-    package="net.ktnx.mobileledger">
+    xmlns:tools="http://schemas.android.com/tools">
 
     <uses-permission android:name="android.permission.INTERNET" />
 
         android:name=".App"
         android:allowBackup="true"
         android:appCategory="productivity"
-        android:fullBackupContent="@xml/backup_descriptor"
         android:icon="@drawable/app_icon"
         android:label="@string/app_name"
         android:networkSecurityConfig="@xml/network_security_config"
         android:roundIcon="@drawable/app_icon_round"
         android:supportsRtl="true"
+        android:backupAgent=".backup.MobileLedgerBackupAgent"
         tools:ignore="GoogleAppIndexingWarning">
+        <activity
+            android:name=".BackupsActivity"
+            android:label="@string/backups_activity_label"
+            android:theme="@style/AppTheme.default" />
+        <activity
+            android:name=".ui.templates.TemplatesActivity"
+            android:label="@string/title_activity_templates"
+            android:theme="@style/AppTheme.default" />
         <activity
             android:name=".ui.activity.SplashActivity"
-            android:label="@string/app_name"
+            android:exported="true"
             android:theme="@style/AppTheme.default">
             <intent-filter>
                 <action android:name="android.intent.action.MAIN" />
         </activity>
         <activity
             android:name=".ui.activity.MainActivity"
-            android:label="@string/app_name"
             android:theme="@style/AppTheme.default" />
         <activity
-            android:name=".ui.activity.NewTransactionActivity"
+            android:name=".ui.new_transaction.NewTransactionActivity"
             android:label="@string/title_activity_new_transaction"
             android:parentActivityName=".ui.activity.MainActivity"
-            android:theme="@style/AppTheme.default" />
+            android:theme="@style/AppTheme.default"
+            android:windowSoftInputMode="stateVisible|adjustResize" />
         <activity
-            android:name=".ui.activity.ProfileDetailActivity"
+            android:name=".ui.profiles.ProfileDetailActivity"
             android:label="@string/title_profile_details"
-            android:parentActivityName=".ui.activity.MainActivity" />
+            android:parentActivityName=".ui.activity.MainActivity"
+            android:windowSoftInputMode="stateVisible|adjustResize" />
     </application>
 
 </manifest>
\ No newline at end of file
index 724627971769db475e7007c799ed1ae81987edf6..a9a416e7bb3c15a8e49c51f1aedb45f1307d0bee 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2020 Damyan Ivanov.
+ * Copyright © 2021 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
 package net.ktnx.mobileledger;
 
 import android.app.Application;
+import android.content.SharedPreferences;
 import android.content.res.Configuration;
 import android.content.res.Resources;
-import android.database.sqlite.SQLiteDatabase;
 import android.util.Log;
 
 import net.ktnx.mobileledger.model.Data;
-import net.ktnx.mobileledger.model.MobileLedgerProfile;
+import net.ktnx.mobileledger.ui.profiles.ProfileDetailModel;
+import net.ktnx.mobileledger.utils.Colors;
 import net.ktnx.mobileledger.utils.Globals;
 import net.ktnx.mobileledger.utils.Logger;
-import net.ktnx.mobileledger.utils.MobileLedgerDatabase;
 
 import org.jetbrains.annotations.NotNull;
 
@@ -38,18 +38,60 @@ import java.net.URL;
 import java.util.Locale;
 
 public class App extends Application {
+    public static final String PREF_NAME = "MoLe";
+    public static final String PREF_THEME_HUE = "theme-hue";
+    public static final String PREF_PROFILE_ID = "profile-id";
     public static App instance;
-    private MobileLedgerDatabase dbHelper;
+    private static ProfileDetailModel profileModel;
     private boolean monthNamesPrepared = false;
-    public static SQLiteDatabase getDatabase() {
-        if (instance == null)
-            throw new RuntimeException("Application not created yet");
-
-        return instance.getDB();
-    }
     public static void prepareMonthNames() {
         instance.prepareMonthNames(false);
     }
+    public static void setAuthenticationDataFromProfileModel(ProfileDetailModel model) {
+        profileModel = model;
+    }
+    public static void resetAuthenticationData() {
+        profileModel = null;
+    }
+    public static void storeStartupProfileAndTheme(long currentProfileId, int currentTheme) {
+        SharedPreferences prefs = instance.getSharedPreferences(PREF_NAME, MODE_PRIVATE);
+        SharedPreferences.Editor editor = prefs.edit();
+        editor.putLong(PREF_PROFILE_ID, currentProfileId);
+        editor.putInt(PREF_THEME_HUE, currentTheme);
+        editor.apply();
+    }
+    public static long getStartupProfile() {
+        SharedPreferences prefs = instance.getSharedPreferences(PREF_NAME, MODE_PRIVATE);
+        return prefs.getLong(PREF_PROFILE_ID, -1);
+    }
+    public static int getStartupTheme() {
+        SharedPreferences prefs = instance.getSharedPreferences(PREF_NAME, MODE_PRIVATE);
+        return prefs.getInt(PREF_THEME_HUE, Colors.DEFAULT_HUE_DEG);
+    }
+    private String getAuthURL() {
+        if (profileModel != null)
+            return profileModel.getUrl();
+        return Data.getProfile()
+                   .getUrl();
+    }
+    private String getAuthUserName() {
+        if (profileModel != null)
+            return profileModel.getAuthUserName();
+        return Data.getProfile()
+                   .getAuthUser();
+    }
+    private String getAuthPassword() {
+        if (profileModel != null)
+            return profileModel.getAuthPassword();
+        return Data.getProfile()
+                   .getAuthPassword();
+    }
+    private boolean getAuthEnabled() {
+        if (profileModel != null)
+            return profileModel.getUseAuthentication();
+        return Data.getProfile()
+                   .useAuthentication();
+    }
     @Override
     public void onCreate() {
         Logger.debug("flow", "App onCreate()");
@@ -59,16 +101,14 @@ public class App extends Application {
         Authenticator.setDefault(new Authenticator() {
             @Override
             protected PasswordAuthentication getPasswordAuthentication() {
-                MobileLedgerProfile p = Data.getProfile();
-                if (p.isAuthEnabled()) {
+                if (getAuthEnabled()) {
                     try {
-                        final URL url = new URL(p.getUrl());
+                        final URL url = new URL(getAuthURL());
                         final String requestingHost = getRequestingHost();
                         final String expectedHost = url.getHost();
                         if (requestingHost.equalsIgnoreCase(expectedHost))
-                            return new PasswordAuthentication(p.getAuthUserName(),
-                                    p.getAuthPassword()
-                                     .toCharArray());
+                            return new PasswordAuthentication(getAuthUserName(),
+                                    getAuthPassword().toCharArray());
                         else
                             Log.w("http-auth",
                                     String.format("Requesting host [%s] differs from expected [%s]",
@@ -91,29 +131,10 @@ public class App extends Application {
         monthNamesPrepared = true;
     }
     @Override
-    public void onTerminate() {
-        Logger.debug("flow", "App onTerminate()");
-        if (dbHelper != null)
-            dbHelper.close();
-        super.onTerminate();
-    }
-    @Override
     public void onConfigurationChanged(@NotNull Configuration newConfig) {
         super.onConfigurationChanged(newConfig);
         prepareMonthNames(true);
         Data.refreshCurrencyData(Locale.getDefault());
         Data.locale.setValue(Locale.getDefault());
     }
-    public SQLiteDatabase getDB() {
-        if (dbHelper == null)
-            initDb();
-
-        return dbHelper.getWritableDatabase();
-    }
-    private synchronized void initDb() {
-        if (dbHelper != null)
-            return;
-
-        dbHelper = new MobileLedgerDatabase(this);
-    }
 }
diff --git a/app/src/main/java/net/ktnx/mobileledger/BackupsActivity.java b/app/src/main/java/net/ktnx/mobileledger/BackupsActivity.java
new file mode 100644 (file)
index 0000000..a2b5cf6
--- /dev/null
@@ -0,0 +1,161 @@
+/*
+ * Copyright © 2022 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger;
+
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.MenuItem;
+import android.view.View;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.google.android.material.snackbar.BaseTransientBottomBar;
+import com.google.android.material.snackbar.Snackbar;
+
+import net.ktnx.mobileledger.backup.ConfigReader;
+import net.ktnx.mobileledger.backup.ConfigWriter;
+import net.ktnx.mobileledger.databinding.FragmentBackupsBinding;
+import net.ktnx.mobileledger.model.Data;
+
+import java.io.IOException;
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+
+public class BackupsActivity extends AppCompatActivity {
+    private FragmentBackupsBinding b;
+    private ActivityResultLauncher<String> backupChooserLauncher;
+    private ActivityResultLauncher<String[]> restoreChooserLauncher;
+    public static void start(Context context) {
+        Intent starter = new Intent(context, BackupsActivity.class);
+        context.startActivity(starter);
+    }
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        b = FragmentBackupsBinding.inflate(getLayoutInflater());
+        setContentView(b.getRoot());
+
+        setSupportActionBar(b.toolbar);
+        // Show the Up button in the action bar.
+        ActionBar actionBar = getSupportActionBar();
+        if (actionBar != null) {
+            actionBar.setDisplayHomeAsUpEnabled(true);
+        }
+
+        b.backupButton.setOnClickListener(this::backupClicked);
+        b.restoreButton.setOnClickListener(this::restoreClicked);
+
+
+        backupChooserLauncher = registerForActivityResult(
+                new ActivityResultContracts.CreateDocument("application/json"), this::storeConfig);
+        restoreChooserLauncher =
+                registerForActivityResult(new ActivityResultContracts.OpenDocument(),
+                        this::readConfig);
+
+        Data.observeProfile(this, p -> {
+            if (p == null) {
+                b.backupButton.setEnabled(false);
+                b.backupExplanationText.setEnabled(false);
+            }
+            else {
+                b.backupButton.setEnabled(true);
+                b.backupExplanationText.setEnabled(true);
+            }
+        });
+    }
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        if (item.getItemId() == android.R.id.home) {
+            finish();
+
+            return true;
+        }
+        return super.onOptionsItemSelected(item);
+    }
+    private void storeConfig(Uri result) {
+        if (result == null)
+            return;
+
+        try {
+            ConfigWriter saver =
+                    new ConfigWriter(getBaseContext(), result, new ConfigWriter.OnErrorListener() {
+                        @Override
+                        public void error(Exception e) {
+                            Snackbar.make(b.backupButton, e.toString(),
+                                    BaseTransientBottomBar.LENGTH_LONG)
+                                    .show();
+                        }
+                    }, new ConfigWriter.OnDoneListener() {
+                        public void done() {
+                            Snackbar.make(b.backupButton, R.string.config_saved,
+                                    Snackbar.LENGTH_LONG)
+                                    .show();
+                        }
+                    });
+            saver.start();
+        }
+        catch (IOException e) {
+            e.printStackTrace();
+        }
+
+    }
+    private void readConfig(Uri result) {
+        if (result == null)
+            return;
+
+        try {
+            ConfigReader reader =
+                    new ConfigReader(getBaseContext(), result, new ConfigWriter.OnErrorListener() {
+                        @Override
+                        public void error(Exception e) {
+                            Snackbar.make(b.backupButton, e.toString(),
+                                    BaseTransientBottomBar.LENGTH_LONG)
+                                    .show();
+                        }
+                    }, new ConfigReader.OnDoneListener() {
+                        public void done() {
+                            Snackbar.make(b.backupButton, R.string.config_restored,
+                                    Snackbar.LENGTH_LONG)
+                                    .show();
+                        }
+                    });
+            reader.start();
+        }
+        catch (IOException e) {
+            e.printStackTrace();
+        }
+
+    }
+    private void backupClicked(View view) {
+        final Date now = new Date();
+        DateFormat df = new SimpleDateFormat("y-MM-dd HH:mm", Locale.getDefault());
+        df.format(now);
+        backupChooserLauncher.launch(String.format("MoLe-%s.json", df.format(now)));
+    }
+    private void restoreClicked(View view) {
+        restoreChooserLauncher.launch(new String[]{"application/json"});
+    }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/ktnx/mobileledger/async/DbOpItem.java b/app/src/main/java/net/ktnx/mobileledger/async/DbOpItem.java
deleted file mode 100644 (file)
index 2b0b61f..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * Copyright © 2020 Damyan Ivanov.
- * This file is part of MoLe.
- * MoLe is free software: you can distribute it and/or modify it
- * under the term of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your opinion), any later version.
- *
- * MoLe is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License terms for details.
- *
- * You should have received a copy of the GNU General Public License
- * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package net.ktnx.mobileledger.async;
-
-class DbOpItem {
-    final String sql;
-    final Object[] params;
-    final Runnable onReady;
-    public DbOpItem(String sql, Object[] params, Runnable onReady) {
-        this.sql = sql;
-        this.params = params;
-        this.onReady = onReady;
-    }
-    public DbOpItem(String sql, Object[] params) {
-        this(sql, params, null);
-    }
-    public DbOpItem(String sql) {
-        this(sql, null, null);
-    }
-}
diff --git a/app/src/main/java/net/ktnx/mobileledger/async/DbOpQueue.java b/app/src/main/java/net/ktnx/mobileledger/async/DbOpQueue.java
deleted file mode 100644 (file)
index 081bbee..0000000
+++ /dev/null
@@ -1,47 +0,0 @@
-/*
- * Copyright © 2020 Damyan Ivanov.
- * This file is part of MoLe.
- * MoLe is free software: you can distribute it and/or modify it
- * under the term of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your opinion), any later version.
- *
- * MoLe is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License terms for details.
- *
- * You should have received a copy of the GNU General Public License
- * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package net.ktnx.mobileledger.async;
-
-import java.util.concurrent.BlockingQueue;
-import java.util.concurrent.LinkedBlockingQueue;
-
-import static net.ktnx.mobileledger.utils.Logger.debug;
-
-public class DbOpQueue {
-    static private final BlockingQueue<DbOpItem> queue = new LinkedBlockingQueue<>();
-    static private DbOpRunner runner;
-    synchronized static public void init() {
-        if (runner != null)
-            return;
-        debug("opQueue", "Starting runner thread");
-        runner = new DbOpRunner(queue);
-        runner.start();
-    }
-    static public void done() {
-        runner.interrupt();
-    }
-    public static void add(String sql, Object[] params) {add(sql, params, null);}
-    public static void add(String sql, Object[] params, Runnable onReady) {
-        init();
-        debug("opQueue", "Adding " + sql);
-        queue.add(new DbOpItem(sql, params, onReady));
-    }
-    static void add(String sql) {
-        queue.add(new DbOpItem(sql));
-    }
-}
diff --git a/app/src/main/java/net/ktnx/mobileledger/async/DbOpRunner.java b/app/src/main/java/net/ktnx/mobileledger/async/DbOpRunner.java
deleted file mode 100644 (file)
index f8ea577..0000000
+++ /dev/null
@@ -1,69 +0,0 @@
-/*
- * Copyright © 2020 Damyan Ivanov.
- * This file is part of MoLe.
- * MoLe is free software: you can distribute it and/or modify it
- * under the term of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your opinion), any later version.
- *
- * MoLe is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License terms for details.
- *
- * You should have received a copy of the GNU General Public License
- * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package net.ktnx.mobileledger.async;
-
-import android.database.sqlite.SQLiteDatabase;
-
-import net.ktnx.mobileledger.App;
-import net.ktnx.mobileledger.BuildConfig;
-
-import java.util.concurrent.BlockingQueue;
-
-import static net.ktnx.mobileledger.utils.Logger.debug;
-
-class DbOpRunner extends Thread {
-    private final BlockingQueue<DbOpItem> queue;
-    public DbOpRunner(BlockingQueue<DbOpItem> queue) {
-        this.queue = queue;
-    }
-    @Override
-    public void run() {
-        while (!interrupted()) {
-            try {
-                DbOpItem item = queue.take();
-                debug("opQRunner", "Got " + item.sql);
-                {
-                    SQLiteDatabase db = App.getDatabase();
-                    if (BuildConfig.DEBUG) {
-                        StringBuilder b = new StringBuilder("Executing ");
-                        b.append(item.sql);
-                        if (item.params.length > 0) {
-                            boolean first = true;
-                            b.append(" [");
-                            for (Object p : item.params) {
-                                if (first)
-                                    first = false;
-                                else
-                                    b.append(", ");
-                                b.append(p.toString());
-                            }
-                            b.append("]");
-                        }
-                        debug("opQRunner", b.toString());
-                    }
-                    db.execSQL(item.sql, item.params);
-                }
-                if (item.onReady != null)
-                    item.onReady.run();
-            }
-            catch (InterruptedException e) {
-                break;
-            }
-        }
-    }
-}
index 992abd1fafd7263fb477a21620f220fa627c2dbf..20d669413f12c6e6d92014cdecfcf78e6b3cf703 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2019 Damyan Ivanov.
+ * Copyright © 2021 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
@@ -18,5 +18,5 @@
 package net.ktnx.mobileledger.async;
 
 public interface DescriptionSelectedCallback {
-    void descriptionSelected(String description);
+    void onDescriptionSelected(String description);
 }
diff --git a/app/src/main/java/net/ktnx/mobileledger/async/GeneralBackgroundTasks.java b/app/src/main/java/net/ktnx/mobileledger/async/GeneralBackgroundTasks.java
new file mode 100644 (file)
index 0000000..3ff5904
--- /dev/null
@@ -0,0 +1,66 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.async;
+
+import net.ktnx.mobileledger.utils.Misc;
+
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+
+/**
+ * suitable for short tasks, not involving network communication
+ */
+public class GeneralBackgroundTasks {
+    private static final Executor runner = Executors.newFixedThreadPool(Runtime.getRuntime()
+                                                                               .availableProcessors());
+    public static void run(@NotNull Runnable runnable) {
+        runner.execute(runnable);
+    }
+    public static void run(@NotNull Runnable runnable, @NotNull Runnable onSuccess) {
+        runner.execute(() -> {
+            runnable.run();
+            Misc.onMainThread(onSuccess);
+        });
+    }
+    public static void run(@NotNull Runnable runnable, @Nullable Runnable onSuccess,
+                           @Nullable ErrorCallback onError, @Nullable Runnable onDone) {
+        runner.execute(() -> {
+            try {
+                runnable.run();
+                if (onSuccess != null)
+                    Misc.onMainThread(onSuccess);
+            }
+            catch (Exception e) {
+                if (onError != null)
+                    Misc.onMainThread(() -> onError.error(e));
+                else
+                    throw e;
+            }
+            finally {
+                if (onDone != null)
+                    Misc.onMainThread(onDone);
+            }
+        });
+    }
+    public static abstract class ErrorCallback {
+        abstract void error(Exception e);
+    }
+}
index 1d69fa2941a1f0963f5857c560f15bf244e2d751..0b751b4c6d7df0c94174973ec7a2e449352c9dc9 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2020 Damyan Ivanov.
+ * Copyright © 2021 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
 package net.ktnx.mobileledger.async;
 
 import android.annotation.SuppressLint;
-import android.database.sqlite.SQLiteDatabase;
-import android.os.AsyncTask;
 import android.os.OperationCanceledException;
 
 import androidx.annotation.NonNull;
 
-import net.ktnx.mobileledger.App;
+import com.fasterxml.jackson.core.JsonParseException;
+import com.fasterxml.jackson.databind.RuntimeJsonMappingException;
+
+import net.ktnx.mobileledger.dao.AccountDAO;
+import net.ktnx.mobileledger.dao.TransactionDAO;
+import net.ktnx.mobileledger.db.Account;
+import net.ktnx.mobileledger.db.AccountWithAmounts;
+import net.ktnx.mobileledger.db.DB;
+import net.ktnx.mobileledger.db.Option;
+import net.ktnx.mobileledger.db.Profile;
+import net.ktnx.mobileledger.db.TransactionWithAccounts;
 import net.ktnx.mobileledger.err.HTTPException;
-import net.ktnx.mobileledger.json.v1_15.AccountListParser;
-import net.ktnx.mobileledger.json.v1_15.ParsedBalance;
-import net.ktnx.mobileledger.json.v1_15.ParsedLedgerAccount;
-import net.ktnx.mobileledger.json.v1_15.ParsedLedgerTransaction;
-import net.ktnx.mobileledger.json.v1_15.TransactionListParser;
+import net.ktnx.mobileledger.json.API;
+import net.ktnx.mobileledger.json.AccountListParser;
+import net.ktnx.mobileledger.json.ApiNotSupportedException;
+import net.ktnx.mobileledger.json.TransactionListParser;
 import net.ktnx.mobileledger.model.Data;
 import net.ktnx.mobileledger.model.LedgerAccount;
 import net.ktnx.mobileledger.model.LedgerTransaction;
 import net.ktnx.mobileledger.model.LedgerTransactionAccount;
-import net.ktnx.mobileledger.model.MobileLedgerProfile;
-import net.ktnx.mobileledger.ui.MainModel;
+import net.ktnx.mobileledger.utils.Logger;
 import net.ktnx.mobileledger.utils.NetworkUtil;
 
 import java.io.BufferedReader;
@@ -50,6 +56,7 @@ import java.nio.charset.StandardCharsets;
 import java.text.ParseException;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Locale;
@@ -58,8 +65,7 @@ import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 
-public class RetrieveTransactionsTask extends
-        AsyncTask<Void, RetrieveTransactionsTask.Progress, RetrieveTransactionsTask.Result> {
+public class RetrieveTransactionsTask extends Thread {
     private static final int MATCHING_TRANSACTIONS_LIMIT = 150;
     private static final Pattern reComment = Pattern.compile("^\\s*;");
     private static final Pattern reTransactionStart = Pattern.compile(
@@ -73,21 +79,16 @@ public class RetrieveTransactionsTask extends
     private static final Pattern reEnd = Pattern.compile("\\bid=\"addmodal\"");
     private static final Pattern reDecimalPoint = Pattern.compile("\\.\\d\\d?$");
     private static final Pattern reDecimalComma = Pattern.compile(",\\d\\d?$");
+    private static final String TAG = "RTT";
     // %3A is '='
     private final Pattern reAccountName =
             Pattern.compile("/register\\?q=inacct%3A([a-zA-Z0-9%]+)\"");
     private final Pattern reAccountValue = Pattern.compile(
             "<span class=\"[^\"]*\\bamount\\b[^\"]*\">\\s*([-+]?[\\d.,]+)(?:\\s+(\\S+))?</span>");
-    private final MainModel mainModel;
-    private final MobileLedgerProfile profile;
-    private final List<LedgerAccount> prevAccounts;
+    private final Profile profile;
     private int expectedPostingsCount = -1;
-    public RetrieveTransactionsTask(@NonNull MainModel mainModel,
-                                    @NonNull MobileLedgerProfile profile,
-                                    List<LedgerAccount> accounts) {
-        this.mainModel = mainModel;
+    public RetrieveTransactionsTask(@NonNull Profile profile) {
         this.profile = profile;
-        this.prevAccounts = accounts;
     }
     private static void L(String msg) {
         //debug("transaction-parser", msg);
@@ -119,25 +120,19 @@ public class RetrieveTransactionsTask extends
             return null;
         }
     }
-    @Override
-    protected void onProgressUpdate(Progress... values) {
-        super.onProgressUpdate(values);
-        Data.backgroundTaskProgress.postValue(values[0]);
+    private void publishProgress(Progress progress) {
+        Data.backgroundTaskProgress.postValue(progress);
     }
-    @Override
-    protected void onPostExecute(Result result) {
-        super.onPostExecute(result);
+    private void finish(Result result) {
         Progress progress = new Progress();
         progress.setState(ProgressState.FINISHED);
         progress.setError(result.error);
-        onProgressUpdate(progress);
+        publishProgress(progress);
     }
-    @Override
-    protected void onCancelled() {
-        super.onCancelled();
+    private void cancel() {
         Progress progress = new Progress();
         progress.setState(ProgressState.FINISHED);
-        onProgressUpdate(progress);
+        publishProgress(progress);
     }
     private void retrieveTransactionListLegacy(List<LedgerAccount> accounts,
                                                List<LedgerTransaction> transactions)
@@ -209,7 +204,7 @@ public class RetrieveTransactionsTask extends
                             else {
                                 parentAccount = null;
                             }
-                            lastAccount = new LedgerAccount(profile, accName, parentAccount);
+                            lastAccount = new LedgerAccount(accName, parentAccount);
 
                             accounts.add(lastAccount);
                             map.put(accName, lastAccount);
@@ -323,7 +318,7 @@ public class RetrieveTransactionsTask extends
 
                             state = ParserState.EXPECTING_TRANSACTION;
                             L(String.format("transaction %s parsed → expecting transaction",
-                                    transaction.getId()));
+                                    transaction.getLedgerId()));
 
 // sounds like a good idea, but transaction-1 may not be the first one chronologically
 // for example, when you add the initial seeding transaction after entering some others
@@ -338,8 +333,9 @@ public class RetrieveTransactionsTask extends
                             LedgerTransactionAccount lta = parseTransactionAccountLine(line);
                             if (lta != null) {
                                 transaction.addAccount(lta);
-                                L(String.format(Locale.ENGLISH, "%d: %s = %s", transaction.getId(),
-                                        lta.getAccountName(), lta.getAmount()));
+                                L(String.format(Locale.ENGLISH, "%d: %s = %s",
+                                        transaction.getLedgerId(), lta.getAccountName(),
+                                        lta.getAmount()));
                             }
                             else
                                 throw new IllegalStateException(
@@ -356,9 +352,9 @@ public class RetrieveTransactionsTask extends
             throwIfCancelled();
         }
     }
-    private @NonNull
-    LedgerAccount ensureAccountExists(String accountName, HashMap<String, LedgerAccount> map,
-                                      ArrayList<LedgerAccount> createdAccounts) {
+    @NonNull
+    public LedgerAccount ensureAccountExists(String accountName, HashMap<String, LedgerAccount> map,
+                                             ArrayList<LedgerAccount> createdAccounts) {
         LedgerAccount acc = map.get(accountName);
 
         if (acc != null)
@@ -373,11 +369,46 @@ public class RetrieveTransactionsTask extends
             parentAccount = null;
         }
 
-        acc = new LedgerAccount(profile, accountName, parentAccount);
+        acc = new LedgerAccount(accountName, parentAccount);
         createdAccounts.add(acc);
         return acc;
     }
-    private List<LedgerAccount> retrieveAccountList() throws IOException, HTTPException {
+    public void addNumberOfPostings(int number) {
+        expectedPostingsCount += number;
+    }
+    private List<LedgerAccount> retrieveAccountList()
+            throws IOException, HTTPException, ApiNotSupportedException {
+        final API apiVersion = API.valueOf(profile.getApiVersion());
+        if (apiVersion.equals(API.auto)) {
+            return retrieveAccountListAnyVersion();
+        }
+        else if (apiVersion.equals(API.html)) {
+            Logger.debug("json",
+                    "Declining using JSON API for /accounts with configured legacy API version");
+            return null;
+        }
+        else {
+            return retrieveAccountListForVersion(apiVersion);
+        }
+    }
+    private List<LedgerAccount> retrieveAccountListAnyVersion()
+            throws ApiNotSupportedException, IOException, HTTPException {
+        for (API ver : API.allVersions) {
+            try {
+                return retrieveAccountListForVersion(ver);
+            }
+            catch (JsonParseException | RuntimeJsonMappingException e) {
+                Logger.debug("json",
+                        String.format(Locale.US, "Error during account list retrieval using API %s",
+                                ver.getDescription()), e);
+            }
+
+        }
+
+        throw new ApiNotSupportedException();
+    }
+    private List<LedgerAccount> retrieveAccountListForVersion(API version)
+            throws IOException, HTTPException {
         HttpURLConnection http = NetworkUtil.prepareConnection(profile, "accounts");
         http.setAllowUserInteraction(false);
         switch (http.getResponseCode()) {
@@ -389,86 +420,66 @@ public class RetrieveTransactionsTask extends
                 throw new HTTPException(http.getResponseCode(), http.getResponseMessage());
         }
         publishProgress(Progress.indeterminate());
-        SQLiteDatabase db = App.getDatabase();
         ArrayList<LedgerAccount> list = new ArrayList<>();
         HashMap<String, LedgerAccount> map = new HashMap<>();
-        HashMap<String, LedgerAccount> currentMap = new HashMap<>();
-        for (LedgerAccount acc : prevAccounts)
-            currentMap.put(acc.getName(), acc);
         throwIfCancelled();
         try (InputStream resp = http.getInputStream()) {
             throwIfCancelled();
             if (http.getResponseCode() != 200)
                 throw new IOException(String.format("HTTP error %d", http.getResponseCode()));
 
-            AccountListParser parser = new AccountListParser(resp);
+            AccountListParser parser = AccountListParser.forApiVersion(version, resp);
             expectedPostingsCount = 0;
 
             while (true) {
                 throwIfCancelled();
-                ParsedLedgerAccount parsedAccount = parser.nextAccount();
-                if (parsedAccount == null) {
+                LedgerAccount acc = parser.nextAccount(this, map);
+                if (acc == null)
                     break;
-                }
-                expectedPostingsCount += parsedAccount.getAnumpostings();
-                final String accName = parsedAccount.getAname();
-                LedgerAccount acc = map.get(accName);
-                if (acc != null)
-                    throw new RuntimeException(
-                            String.format("Account '%s' already present", acc.getName()));
-                String parentName = LedgerAccount.extractParentName(accName);
-                ArrayList<LedgerAccount> createdParents = new ArrayList<>();
-                LedgerAccount parent;
-                if (parentName == null) {
-                    parent = null;
-                }
-                else {
-                    parent = ensureAccountExists(parentName, map, createdParents);
-                    parent.setHasSubAccounts(true);
-                }
-                acc = new LedgerAccount(profile, accName, parent);
                 list.add(acc);
-                map.put(accName, acc);
-
-                String lastCurrency = null;
-                float lastCurrencyAmount = 0;
-                for (ParsedBalance b : parsedAccount.getAibalance()) {
-                    throwIfCancelled();
-                    final String currency = b.getAcommodity();
-                    final float amount = b.getAquantity()
-                                          .asFloat();
-                    if (currency.equals(lastCurrency)) {
-                        lastCurrencyAmount += amount;
-                    }
-                    else {
-                        if (lastCurrency != null) {
-                            acc.addAmount(lastCurrencyAmount, lastCurrency);
-                        }
-                        lastCurrency = currency;
-                        lastCurrencyAmount = amount;
-                    }
-                }
-                if (lastCurrency != null) {
-                    acc.addAmount(lastCurrencyAmount, lastCurrency);
-                }
-                for (LedgerAccount p : createdParents)
-                    acc.propagateAmountsTo(p);
             }
             throwIfCancelled();
-        }
 
-        // the current account tree may have changed, update the new-to be tree to match
-        for (LedgerAccount acc : list) {
-            LedgerAccount prevData = currentMap.get(acc.getName());
-            if (prevData != null) {
-                acc.setExpanded(prevData.isExpanded());
-                acc.setAmountsExpanded(prevData.amountsExpanded());
-            }
+            Logger.warn("accounts",
+                    String.format(Locale.US, "Got %d accounts using protocol %s", list.size(),
+                            version.getDescription()));
         }
 
         return list;
     }
     private List<LedgerTransaction> retrieveTransactionList()
+            throws ParseException, HTTPException, IOException, ApiNotSupportedException {
+        final API apiVersion = API.valueOf(profile.getApiVersion());
+        if (apiVersion.equals(API.auto)) {
+            return retrieveTransactionListAnyVersion();
+        }
+        else if (apiVersion.equals(API.html)) {
+            Logger.debug("json",
+                    "Declining using JSON API for /accounts with configured legacy API version");
+            return null;
+        }
+        else {
+            return retrieveTransactionListForVersion(apiVersion);
+        }
+
+    }
+    private List<LedgerTransaction> retrieveTransactionListAnyVersion()
+            throws ApiNotSupportedException {
+        for (API ver : API.allVersions) {
+            try {
+                return retrieveTransactionListForVersion(ver);
+            }
+            catch (Exception e) {
+                Logger.debug("json", String.format(Locale.US,
+                        "Error during transaction list retrieval using API %s",
+                        ver.getDescription()), e);
+            }
+
+        }
+
+        throw new ApiNotSupportedException();
+    }
+    private List<LedgerTransaction> retrieveTransactionListForVersion(API apiVersion)
             throws IOException, ParseException, HTTPException {
         Progress progress = new Progress();
         progress.setTotal(expectedPostingsCount);
@@ -488,18 +499,17 @@ public class RetrieveTransactionsTask extends
         try (InputStream resp = http.getInputStream()) {
             throwIfCancelled();
 
-            TransactionListParser parser = new TransactionListParser(resp);
+            TransactionListParser parser = TransactionListParser.forApiVersion(apiVersion, resp);
 
             int processedPostings = 0;
 
             while (true) {
                 throwIfCancelled();
-                ParsedLedgerTransaction parsedTransaction = parser.nextTransaction();
+                LedgerTransaction transaction = parser.nextTransaction();
                 throwIfCancelled();
-                if (parsedTransaction == null)
+                if (transaction == null)
                     break;
 
-                LedgerTransaction transaction = parsedTransaction.asLedgerTransaction();
                 trList.add(transaction);
 
                 progress.setProgress(processedPostings += transaction.getAccounts()
@@ -516,68 +526,88 @@ public class RetrieveTransactionsTask extends
             }
 
             throwIfCancelled();
+
+            Logger.warn("transactions",
+                    String.format(Locale.US, "Got %d transactions using protocol %s", trList.size(),
+                            apiVersion.getDescription()));
         }
 
-        // json interface returns transactions if file order and the rest of the machinery
+        // json interface returns transactions in file order and the rest of the machinery
         // expects them in reverse chronological order
         Collections.sort(trList, (o1, o2) -> {
             int res = o2.getDate()
                         .compareTo(o1.getDate());
             if (res != 0)
                 return res;
-            return Integer.compare(o2.getId(), o1.getId());
+            return Long.compare(o2.getLedgerId(), o1.getLedgerId());
         });
         return trList;
     }
 
     @SuppressLint("DefaultLocale")
     @Override
-    protected Result doInBackground(Void... params) {
+    public void run() {
         Data.backgroundTaskStarted();
         List<LedgerAccount> accounts;
         List<LedgerTransaction> transactions;
         try {
             accounts = retrieveAccountList();
+            // accounts is null in API-version auto-detection and means
+            // requesting 'html' API version via the JSON classes
+            // this can't work, and the null results in the legacy code below
+            // being called
             if (accounts == null)
                 transactions = null;
             else
                 transactions = retrieveTransactionList();
+
             if (accounts == null || transactions == null) {
                 accounts = new ArrayList<>();
                 transactions = new ArrayList<>();
                 retrieveTransactionListLegacy(accounts, transactions);
             }
-            mainModel.setAndStoreAccountAndTransactionListFromWeb(accounts, transactions);
 
-            return new Result(accounts, transactions);
+            new AccountAndTransactionListSaver(accounts, transactions).start();
+
+            Data.lastUpdateDate.postValue(new Date());
+
+            finish(new Result(null));
         }
         catch (MalformedURLException e) {
             e.printStackTrace();
-            return new Result("Invalid server URL");
+            finish(new Result("Invalid server URL"));
         }
         catch (HTTPException e) {
             e.printStackTrace();
-            return new Result(String.format("HTTP error %d: %s", e.getResponseCode(),
-                    e.getResponseMessage()));
+            finish(new Result(
+                    String.format("HTTP error %d: %s", e.getResponseCode(), e.getMessage())));
         }
         catch (IOException e) {
             e.printStackTrace();
-            return new Result(e.getLocalizedMessage());
+            finish(new Result(e.getLocalizedMessage()));
+        }
+        catch (RuntimeJsonMappingException e) {
+            e.printStackTrace();
+            finish(new Result(Result.ERR_JSON_PARSER_ERROR));
         }
         catch (ParseException e) {
             e.printStackTrace();
-            return new Result("Network error");
+            finish(new Result("Network error"));
         }
         catch (OperationCanceledException e) {
+            Logger.debug("RTT", "Retrieval was cancelled", e);
+            finish(new Result(null));
+        }
+        catch (ApiNotSupportedException e) {
             e.printStackTrace();
-            return new Result("Operation cancelled");
+            finish(new Result("Server version not supported"));
         }
         finally {
             Data.backgroundTaskFinished();
         }
     }
-    private void throwIfCancelled() {
-        if (isCancelled())
+    public void throwIfCancelled() {
+        if (isInterrupted())
             throw new OperationCanceledException(null);
     }
     private enum ParserState {
@@ -661,6 +691,7 @@ public class RetrieveTransactionsTask extends
     }
 
     public static class Result {
+        public static String ERR_JSON_PARSER_ERROR = "err_json_parser";
         public String error;
         public List<LedgerAccount> accounts;
         public List<LedgerTransaction> transactions;
@@ -672,4 +703,55 @@ public class RetrieveTransactionsTask extends
             this.transactions = transactions;
         }
     }
+
+    private class AccountAndTransactionListSaver extends Thread {
+        private final List<LedgerAccount> accounts;
+        private final List<LedgerTransaction> transactions;
+        public AccountAndTransactionListSaver(List<LedgerAccount> accounts,
+                                              List<LedgerTransaction> transactions) {
+            this.accounts = accounts;
+            this.transactions = transactions;
+        }
+        @Override
+        public void run() {
+            AccountDAO accDao = DB.get()
+                                  .getAccountDAO();
+            TransactionDAO trDao = DB.get()
+                                     .getTransactionDAO();
+
+            Logger.debug(TAG, "Preparing account list");
+            final List<AccountWithAmounts> list = new ArrayList<>();
+            for (LedgerAccount acc : accounts) {
+                final AccountWithAmounts a = acc.toDBOWithAmounts();
+                Account existing = accDao.getByNameSync(profile.getId(), acc.getName());
+                if (existing != null) {
+                    a.account.setExpanded(existing.isExpanded());
+                    a.account.setAmountsExpanded(existing.isAmountsExpanded());
+                    a.account.setId(existing.getId()); // not strictly needed, but since we have it
+                    // anyway...
+                }
+
+                list.add(a);
+            }
+            Logger.debug(TAG, "Account list prepared. Storing");
+            accDao.storeAccountsSync(list, profile.getId());
+            Logger.debug(TAG, "Account list stored");
+
+            Logger.debug(TAG, "Preparing transaction list");
+            final List<TransactionWithAccounts> tranList = new ArrayList<>();
+
+            for (LedgerTransaction tr : transactions)
+                tranList.add(tr.toDBO());
+
+            Logger.debug(TAG, "Storing transaction list");
+            trDao.storeTransactionsSync(tranList, profile.getId());
+
+            Logger.debug(TAG, "Transactions stored");
+
+            DB.get()
+              .getOptionDAO()
+              .insertSync(new Option(profile.getId(), Option.OPT_LAST_SCRAPE,
+                      String.valueOf((new Date()).getTime())));
+        }
+    }
 }
index 1c4be5c47fea6789c6a22c3baa1b93928294775b..0e700cca9a510be8e210fafccbe548b88c2a427a 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2020 Damyan Ivanov.
+ * Copyright © 2023 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
 
 package net.ktnx.mobileledger.async;
 
-import android.content.res.Resources;
-import android.os.AsyncTask;
-import android.util.Log;
-import android.util.SparseArray;
+import static net.ktnx.mobileledger.utils.Logger.debug;
 
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.databind.ObjectWriter;
+import android.util.Log;
 
-import net.ktnx.mobileledger.R;
+import net.ktnx.mobileledger.db.Profile;
+import net.ktnx.mobileledger.json.API;
+import net.ktnx.mobileledger.json.ApiNotSupportedException;
+import net.ktnx.mobileledger.json.Gateway;
 import net.ktnx.mobileledger.model.LedgerTransaction;
 import net.ktnx.mobileledger.model.LedgerTransactionAccount;
-import net.ktnx.mobileledger.model.MobileLedgerProfile;
 import net.ktnx.mobileledger.utils.Globals;
 import net.ktnx.mobileledger.utils.Logger;
+import net.ktnx.mobileledger.utils.Misc;
 import net.ktnx.mobileledger.utils.NetworkUtil;
 import net.ktnx.mobileledger.utils.SimpleDate;
 import net.ktnx.mobileledger.utils.UrlEncodedFormData;
@@ -48,68 +47,42 @@ import java.util.Map;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
-import static android.os.SystemClock.sleep;
-import static net.ktnx.mobileledger.utils.Logger.debug;
-
 /* TODO: get rid of the custom session/cookie and auth code?
  *       (the last problem with the POST was the missing content-length header)
  *       This will resolve itself when hledger-web 1.14+ is released with Debian/stable,
  *       at which point the HTML form emulation can be dropped entirely
  */
 
-public class SendTransactionTask extends AsyncTask<LedgerTransaction, Void, Void> {
+public class SendTransactionTask extends Thread {
     private final TaskCallback taskCallback;
-    private final MobileLedgerProfile mProfile;
+    private final Profile mProfile;
     private final boolean simulate;
+    private final LedgerTransaction transaction;
     protected String error;
     private String token;
     private String session;
-    private LedgerTransaction transaction;
 
-    public SendTransactionTask(TaskCallback callback, MobileLedgerProfile profile,
-                               boolean simulate) {
+    public SendTransactionTask(TaskCallback callback, Profile profile,
+                               LedgerTransaction transaction, boolean simulate) {
         taskCallback = callback;
         mProfile = profile;
+        this.transaction = transaction;
         this.simulate = simulate;
     }
-    public SendTransactionTask(TaskCallback callback, MobileLedgerProfile profile) {
-        taskCallback = callback;
-        mProfile = profile;
-        simulate = false;
-    }
-    private boolean send_1_15_OK() throws IOException {
+    private void sendOK(API apiVersion) throws IOException, ApiNotSupportedException {
         HttpURLConnection http = NetworkUtil.prepareConnection(mProfile, "add");
         http.setRequestMethod("PUT");
         http.setRequestProperty("Content-Type", "application/json");
         http.setRequestProperty("Accept", "*/*");
 
-        net.ktnx.mobileledger.json.v1_15.ParsedLedgerTransaction jsonTransaction =
-                net.ktnx.mobileledger.json.v1_15.ParsedLedgerTransaction.fromLedgerTransaction(
-                        transaction);
-        ObjectMapper mapper = new ObjectMapper();
-        ObjectWriter writer =
-                mapper.writerFor(net.ktnx.mobileledger.json.v1_15.ParsedLedgerTransaction.class);
-        String body = writer.writeValueAsString(jsonTransaction);
+        Gateway gateway = Gateway.forApiVersion(apiVersion);
+        String body = gateway.transactionSaveRequest(transaction);
 
-        return sendRequest(http, body);
+        Logger.debug("network", "Sending using API " + apiVersion);
+        sendRequest(http, body);
     }
-    private boolean send_1_14_OK() throws IOException {
-        HttpURLConnection http = NetworkUtil.prepareConnection(mProfile, "add");
-        http.setRequestMethod("PUT");
-        http.setRequestProperty("Content-Type", "application/json");
-        http.setRequestProperty("Accept", "*/*");
-
-        net.ktnx.mobileledger.json.v1_14.ParsedLedgerTransaction jsonTransaction =
-                net.ktnx.mobileledger.json.v1_14.ParsedLedgerTransaction.fromLedgerTransaction(
-                        transaction);
-        ObjectMapper mapper = new ObjectMapper();
-        ObjectWriter writer =
-                mapper.writerFor(net.ktnx.mobileledger.json.v1_14.ParsedLedgerTransaction.class);
-        String body = writer.writeValueAsString(jsonTransaction);
-
-        return sendRequest(http, body);
-    }
-    private boolean sendRequest(HttpURLConnection http, String body) throws IOException {
+    private void sendRequest(HttpURLConnection http, String body)
+            throws IOException, ApiNotSupportedException {
         if (simulate) {
             debug("network", "The request would be: " + body);
             try {
@@ -121,7 +94,7 @@ public class SendTransactionTask extends AsyncTask<LedgerTransaction, Void, Void
                 Logger.debug("network", ex.toString());
             }
 
-            return true;
+            return;
         }
 
         byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8);
@@ -149,16 +122,21 @@ public class SendTransactionTask extends AsyncTask<LedgerTransaction, Void, Void
                     case 400:
                     case 405: {
                         BufferedReader reader = new BufferedReader(new InputStreamReader(resp));
-                        String line;
+                        StringBuilder errorLines = new StringBuilder();
                         int count = 0;
                         while (count <= 5) {
-                            line = reader.readLine();
+                            String line = reader.readLine();
                             if (line == null)
                                 break;
                             Logger.debug("network", line);
+
+                            if (errorLines.length() != 0)
+                                errorLines.append("\n");
+
+                            errorLines.append(line);
                             count++;
                         }
-                        return false; // will cause a retry with the legacy method
+                        throw new ApiNotSupportedException(errorLines.toString());
                     }
                     default:
                         BufferedReader reader = new BufferedReader(new InputStreamReader(resp));
@@ -169,8 +147,6 @@ public class SendTransactionTask extends AsyncTask<LedgerTransaction, Void, Void
                 }
             }
         }
-
-        return true;
     }
     private boolean legacySendOK() throws IOException {
         HttpURLConnection http = NetworkUtil.prepareConnection(mProfile, "add");
@@ -267,49 +243,51 @@ public class SendTransactionTask extends AsyncTask<LedgerTransaction, Void, Void
         }
     }
     @Override
-    protected Void doInBackground(LedgerTransaction... ledgerTransactions) {
+    public void run() {
         error = null;
         try {
-            transaction = ledgerTransactions[0];
-
-            switch (mProfile.getApiVersion()) {
+            final API profileApiVersion = API.valueOf(mProfile.getApiVersion());
+            switch (profileApiVersion) {
                 case auto:
-                    Logger.debug("network", "Trying version 1.5.");
-                    if (!send_1_15_OK()) {
-                        Logger.debug("network", "Version 1.5 request failed. Trying with 1.14");
-                        if (!send_1_14_OK()) {
-                            Logger.debug("network",
-                                    "Version 1.14 failed too. Trying HTML form emulation");
-                            legacySendOkWithRetry();
+                    boolean sendOK = false;
+                    for (API ver : API.allVersions) {
+                        Logger.debug("network", "Trying version " + ver);
+                        try {
+                            sendOK(ver);
+                            sendOK = true;
+                            Logger.debug("network", "Version " + ver + " request succeeded");
+
+                            break;
                         }
-                        else {
-                            Logger.debug("network", "Version 1.14 request succeeded");
+                        catch (ApiNotSupportedException e) {
+                            Logger.debug("network", "Version " + ver + " seems not supported");
                         }
                     }
-                    else {
-                        Logger.debug("network", "Version 1.15 request succeeded");
+
+                    if (!sendOK) {
+                        Logger.debug("network", "Trying HTML form emulation");
+                        legacySendOkWithRetry();
                     }
                     break;
                 case html:
                     legacySendOkWithRetry();
                     break;
-                case pre_1_15:
-                    send_1_14_OK();
-                    break;
-                case post_1_14:
-                    send_1_15_OK();
+                case v1_14:
+                case v1_15:
+                case v1_19_1:
+                case v1_23:
+                    sendOK(profileApiVersion);
                     break;
                 default:
-                    throw new IllegalStateException(
-                            "Unexpected API version: " + mProfile.getApiVersion());
+                    throw new IllegalStateException("Unexpected API version: " + profileApiVersion);
             }
         }
-        catch (Exception e) {
+        catch (ApiNotSupportedException | Exception e) {
             e.printStackTrace();
             error = e.getMessage();
         }
 
-        return null;
+        Misc.onMainThread(() -> taskCallback.onTransactionSaveDone(error, transaction));
     }
     private void legacySendOkWithRetry() throws IOException {
         int tried = 0;
@@ -317,49 +295,13 @@ public class SendTransactionTask extends AsyncTask<LedgerTransaction, Void, Void
             tried++;
             if (tried >= 2)
                 throw new IOException(String.format("aborting after %d tries", tried));
-            sleep(100);
-        }
-    }
-    @Override
-    protected void onPostExecute(Void aVoid) {
-        super.onPostExecute(aVoid);
-        taskCallback.done(error);
-    }
-
-    public enum API {
-        auto(0), html(-1), pre_1_15(-2), post_1_14(-3);
-        private static final SparseArray<API> map = new SparseArray<>();
-
-        static {
-            for (API item : API.values()) {
-                map.put(item.value, item);
+            try {
+                sleep(100);
             }
-        }
-
-        private int value;
-
-        API(int value) {
-            this.value = value;
-        }
-        public static API valueOf(int i) {
-            return map.get(i, auto);
-        }
-        public int toInt() {
-            return this.value;
-        }
-        public String getDescription(Resources resources) {
-            switch (this) {
-                case auto:
-                    return resources.getString(R.string.api_auto);
-                case html:
-                    return resources.getString(R.string.api_html);
-                case pre_1_15:
-                    return resources.getString(R.string.api_pre_1_15);
-                case post_1_14:
-                    return resources.getString(R.string.api_post_1_14);
-                default:
-                    throw new IllegalStateException("Unexpected value: " + value);
+            catch (InterruptedException e) {
+                e.printStackTrace();
+                break;
             }
         }
     }
-}
+}
\ No newline at end of file
index c1db43dadbe666c5cf6e5040cf7b3caf6a030bde..3f065e0b7b1d1e50088909ef7b9cb444f6518722 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2019 Damyan Ivanov.
+ * Copyright © 2021 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
@@ -18,5 +18,5 @@
 package net.ktnx.mobileledger.async;
 
 public interface TaskCallback {
-    void done(String error);
+    void onTransactionSaveDone(String error, Object args);
 }
index e7d21ed202a0973a3ad95bbec797421f7a13a28c..d3131f776e2181807193f494be38b32c4ad44fe0 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2020 Damyan Ivanov.
+ * Copyright © 2021 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
 
 package net.ktnx.mobileledger.async;
 
+import androidx.annotation.Nullable;
+
+import net.ktnx.mobileledger.model.Data;
+import net.ktnx.mobileledger.model.LedgerAccount;
 import net.ktnx.mobileledger.model.LedgerTransaction;
+import net.ktnx.mobileledger.model.LedgerTransactionAccount;
 import net.ktnx.mobileledger.model.TransactionListItem;
 import net.ktnx.mobileledger.ui.MainModel;
+import net.ktnx.mobileledger.utils.Misc;
 import net.ktnx.mobileledger.utils.SimpleDate;
 
+import java.math.BigDecimal;
+import java.math.RoundingMode;
 import java.util.ArrayList;
+import java.util.HashMap;
 
 public class TransactionAccumulator {
     private final ArrayList<TransactionListItem> list = new ArrayList<>();
-    private final MainModel model;
+    private final String boldAccountName;
+    private final String accumulateAccount;
+    private final HashMap<String, BigDecimal> runningTotal = new HashMap<>();
     private SimpleDate earliestDate, latestDate;
     private SimpleDate lastDate;
-    private boolean done;
     private int transactionCount = 0;
-    public TransactionAccumulator(MainModel model) {
-        this.model = model;
+    public TransactionAccumulator(@Nullable String boldAccountName,
+                                  @Nullable String accumulateAccount) {
+        this.boldAccountName = boldAccountName;
+        this.accumulateAccount = accumulateAccount;
 
         list.add(new TransactionListItem());    // head item
     }
+    public void put(LedgerTransaction transaction) {
+        put(transaction, transaction.getDate());
+    }
     public void put(LedgerTransaction transaction, SimpleDate date) {
-        if (done)
-            throw new IllegalStateException("Can't put new items after done()");
+        transactionCount++;
 
         // first item
-        if (null == latestDate)
-            latestDate = date;
-        earliestDate = date;
+        if (null == earliestDate)
+            earliestDate = date;
+        latestDate = date;
 
-        if (!date.equals(lastDate)) {
-            if (lastDate == null)
-                lastDate = SimpleDate.today();
+        if (lastDate != null && !date.equals(lastDate)) {
             boolean showMonth = date.month != lastDate.month || date.year != lastDate.year;
-            list.add(new TransactionListItem(date, showMonth));
+            list.add(1, new TransactionListItem(lastDate, showMonth));
         }
 
-        list.add(new TransactionListItem(transaction));
+        String currentTotal = null;
+        if (accumulateAccount != null) {
+            for (LedgerTransactionAccount acc : transaction.getAccounts()) {
+                if (acc.getAccountName()
+                       .equals(accumulateAccount) ||
+                    LedgerAccount.isParentOf(accumulateAccount, acc.getAccountName()))
+                {
+                    BigDecimal amt = runningTotal.get(acc.getCurrency());
+                    if (amt == null)
+                        amt = BigDecimal.ZERO;
+                    BigDecimal newAmount = BigDecimal.valueOf(acc.getAmount());
+                    newAmount = newAmount.setScale(2, RoundingMode.HALF_EVEN);
+                    amt = amt.add(newAmount);
+                    runningTotal.put(acc.getCurrency(), amt);
+                }
+            }
+
+            currentTotal = summarizeRunningTotal(runningTotal);
+        }
+        list.add(1, new TransactionListItem(transaction, boldAccountName, currentTotal));
 
         lastDate = date;
-        transactionCount++;
     }
-    public void done() {
-        done = true;
+    private String summarizeRunningTotal(HashMap<String, BigDecimal> runningTotal) {
+        StringBuilder b = new StringBuilder();
+        for (String currency : runningTotal.keySet()) {
+            if (b.length() != 0)
+                b.append('\n');
+            if (Misc.emptyIsNull(currency) != null)
+                b.append(currency)
+                 .append(' ');
+            BigDecimal val = runningTotal.get(currency);
+            b.append(Data.formatNumber((val == null) ? 0f : val.floatValue()));
+        }
+        return b.toString();
+    }
+    public void publishResults(MainModel model) {
+        if (lastDate != null) {
+            SimpleDate today = SimpleDate.today();
+            if (!lastDate.equals(today)) {
+                boolean showMonth = today.month != lastDate.month || today.year != lastDate.year;
+                list.add(1, new TransactionListItem(lastDate, showMonth));
+            }
+        }
+
         model.setDisplayedTransactions(list, transactionCount);
         model.setFirstTransactionDate(earliestDate);
         model.setLastTransactionDate(latestDate);
index 608b477198af04f99c6a7bf49cc05b6f310892d7..18cea62fa2b3f415f49598d1054bda02106d5d87 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2020 Damyan Ivanov.
+ * Copyright © 2021 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
@@ -17,8 +17,6 @@
 
 package net.ktnx.mobileledger.async;
 
-import android.os.AsyncTask;
-
 import net.ktnx.mobileledger.model.TransactionListItem;
 import net.ktnx.mobileledger.ui.MainModel;
 import net.ktnx.mobileledger.utils.Logger;
@@ -32,49 +30,42 @@ import java.util.List;
 import java.util.Locale;
 import java.util.Objects;
 
-public class TransactionDateFinder extends AsyncTask<TransactionDateFinder.Params, Void, Integer> {
-    private MainModel model;
-    @Override
-    protected void onPostExecute(Integer pos) {
-        model.foundTransactionItemIndex.setValue(pos);
+public class TransactionDateFinder extends Thread {
+    private final MainModel model;
+    private final SimpleDate date;
+    public TransactionDateFinder(MainModel model, SimpleDate date) {
+        this.model = model;
+        this.date = date;
     }
     @Override
-    protected Integer doInBackground(Params... param) {
-        this.model = param[0].model;
-        SimpleDate date = param[0].date;
+    public void run() {
         Logger.debug("go-to-date",
                 String.format(Locale.US, "Looking for date %04d-%02d-%02d", date.year, date.month,
                         date.day));
         List<TransactionListItem> transactions = Objects.requireNonNull(
-                param[0].model.getDisplayedTransactions()
-                              .getValue());
+                model.getDisplayedTransactions()
+                     .getValue());
+        final int transactionCount = transactions.size();
         Logger.debug("go-to-date",
-                String.format(Locale.US, "List contains %d transactions", transactions.size()));
+                String.format(Locale.US, "List contains %d transactions", transactionCount));
 
         TransactionListItem target = new TransactionListItem(date, true);
         int found =
                 Collections.binarySearch(transactions, target, new TransactionListItemComparator());
-        if (found >= 0)
-            return found;
-        else
-            return 1 - found;
-    }
+        if (found < 0)
+            found = -1 - found;
 
-    public static class Params {
-        public final MainModel model;
-        public final SimpleDate date;
-        public Params(@NotNull MainModel model, @NotNull SimpleDate date) {
-            this.model = model;
-            this.date = date;
-        }
+        model.foundTransactionItemIndex.postValue(found);
     }
 
     static class TransactionListItemComparator implements Comparator<TransactionListItem> {
         @Override
         public int compare(@NotNull TransactionListItem a, @NotNull TransactionListItem b) {
-            if (a.getType() == TransactionListItem.Type.HEADER)
+            final TransactionListItem.Type aType = a.getType();
+            if (aType == TransactionListItem.Type.HEADER)
                 return +1;
-            if (b.getType() == TransactionListItem.Type.HEADER)
+            final TransactionListItem.Type bType = b.getType();
+            if (bType == TransactionListItem.Type.HEADER)
                 return -1;
             final SimpleDate aDate = a.getDate();
             final SimpleDate bDate = b.getDate();
@@ -82,14 +73,14 @@ public class TransactionDateFinder extends AsyncTask<TransactionDateFinder.Param
             if (res != 0)
                 return -res;    // transactions are reverse sorted by date
 
-            if (a.getType() == TransactionListItem.Type.DELIMITER) {
-                if (b.getType() == TransactionListItem.Type.DELIMITER)
+            if (aType == TransactionListItem.Type.DELIMITER) {
+                if (bType == TransactionListItem.Type.DELIMITER)
                     return 0;
                 else
                     return -1;
             }
             else {
-                if (b.getType() == TransactionListItem.Type.DELIMITER)
+                if (bType == TransactionListItem.Type.DELIMITER)
                     return +1;
                 else
                     return 0;
diff --git a/app/src/main/java/net/ktnx/mobileledger/async/UpdateTransactionsTask.java b/app/src/main/java/net/ktnx/mobileledger/async/UpdateTransactionsTask.java
deleted file mode 100644 (file)
index 43de740..0000000
+++ /dev/null
@@ -1,84 +0,0 @@
-/*
- * Copyright © 2020 Damyan Ivanov.
- * This file is part of MoLe.
- * MoLe is free software: you can distribute it and/or modify it
- * under the term of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your opinion), any later version.
- *
- * MoLe is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License terms for details.
- *
- * You should have received a copy of the GNU General Public License
- * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package net.ktnx.mobileledger.async;
-
-import android.database.Cursor;
-import android.database.sqlite.SQLiteDatabase;
-import android.os.AsyncTask;
-
-import net.ktnx.mobileledger.App;
-import net.ktnx.mobileledger.model.Data;
-import net.ktnx.mobileledger.model.LedgerTransaction;
-import net.ktnx.mobileledger.model.MobileLedgerProfile;
-import net.ktnx.mobileledger.ui.MainModel;
-import net.ktnx.mobileledger.utils.SimpleDate;
-
-import static net.ktnx.mobileledger.utils.Logger.debug;
-
-public class UpdateTransactionsTask extends AsyncTask<MainModel, Void, String> {
-    protected String doInBackground(MainModel[] model) {
-        final MobileLedgerProfile profile = Data.getProfile();
-
-        String profile_uuid = profile.getUuid();
-        Data.backgroundTaskStarted();
-        try {
-            String sql;
-            String[] params;
-
-            final String accFilter = model[0].getAccountFilter()
-                                             .getValue();
-            if (accFilter == null) {
-                sql = "SELECT id, year, month, day FROM transactions WHERE profile=? ORDER BY " +
-                      "year desc, month desc, day desc, id desc";
-                params = new String[]{profile_uuid};
-
-            }
-            else {
-                sql = "SELECT distinct tr.id, tr.year, tr.month, tr.day from transactions tr " +
-                      "JOIN " + "transaction_accounts ta " +
-                      "ON ta.transaction_id=tr.id AND ta.profile=tr.profile WHERE tr.profile=? " +
-                      "and ta.account_name LIKE ?||'%' AND ta" +
-                      ".amount <> 0 ORDER BY tr.year desc, tr.month desc, tr.day desc, tr.id " +
-                      "desc";
-                params = new String[]{profile_uuid, accFilter};
-            }
-
-            debug("UTT", sql);
-            TransactionAccumulator accumulator = new TransactionAccumulator(model[0]);
-
-            SQLiteDatabase db = App.getDatabase();
-            try (Cursor cursor = db.rawQuery(sql, params)) {
-                while (cursor.moveToNext()) {
-                    if (isCancelled())
-                        return null;
-
-                    accumulator.put(new LedgerTransaction(cursor.getInt(0)),
-                            new SimpleDate(cursor.getInt(1), cursor.getInt(2), cursor.getInt(3)));
-                }
-            }
-
-            accumulator.done();
-            debug("UTT", "transaction list value updated");
-
-            return null;
-        }
-        finally {
-            Data.backgroundTaskFinished();
-        }
-    }
-}
diff --git a/app/src/main/java/net/ktnx/mobileledger/backup/ConfigIO.java b/app/src/main/java/net/ktnx/mobileledger/backup/ConfigIO.java
new file mode 100644 (file)
index 0000000..ec7a949
--- /dev/null
@@ -0,0 +1,111 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.backup;
+
+import android.content.Context;
+import android.net.Uri;
+import android.os.ParcelFileDescriptor;
+import android.util.Log;
+
+import net.ktnx.mobileledger.utils.Misc;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+
+abstract class ConfigIO extends Thread {
+    protected final OnErrorListener onErrorListener;
+    protected ParcelFileDescriptor pfd;
+    ConfigIO(Context context, Uri uri, OnErrorListener onErrorListener)
+            throws FileNotFoundException {
+        this.onErrorListener = onErrorListener;
+        pfd = context.getContentResolver()
+                     .openFileDescriptor(uri, getStreamMode());
+
+        initStream();
+    }
+    abstract protected String getStreamMode();
+
+    abstract protected void initStream();
+
+    abstract protected void processStream() throws IOException;
+    @Override
+    public void run() {
+        try {
+            processStream();
+        }
+        catch (Exception e) {
+            Log.e("cfg-json", "Error processing settings as JSON", e);
+            if (onErrorListener != null)
+                Misc.onMainThread(() -> onErrorListener.error(e));
+        }
+        finally {
+            try {
+                pfd.close();
+            }
+            catch (Exception e) {
+                Log.e("cfg-json", "Error closing file descriptor", e);
+            }
+        }
+    }
+    protected static class Keys {
+        static final String ACCOUNTS = "accounts";
+        static final String AMOUNT = "amount";
+        static final String AMOUNT_GROUP = "amountGroup";
+        static final String API_VER = "apiVersion";
+        static final String AUTH_PASS = "authPass";
+        static final String AUTH_USER = "authUser";
+        static final String CAN_POST = "permitPosting";
+        static final String COLOUR = "colour";
+        static final String COMMENT = "comment";
+        static final String COMMENT_GROUP = "commentMatchGroup";
+        static final String COMMODITIES = "commodities";
+        static final String CURRENCY = "commodity";
+        static final String CURRENCY_GROUP = "commodityGroup";
+        static final String CURRENT_PROFILE = "currentProfile";
+        static final String DATE_DAY = "dateDay";
+        static final String DATE_DAY_GROUP = "dateDayMatchGroup";
+        static final String DATE_MONTH = "dateMonth";
+        static final String DATE_MONTH_GROUP = "dateMonthMatchGroup";
+        static final String DATE_YEAR = "dateYear";
+        static final String DATE_YEAR_GROUP = "dateYearMatchGroup";
+        static final String DEFAULT_COMMODITY = "defaultCommodity";
+        static final String FUTURE_DATES = "futureDates";
+        static final String HAS_GAP = "hasGap";
+        static final String IS_FALLBACK = "isFallback";
+        static final String NAME = "name";
+        static final String NAME_GROUP = "nameMatchGroup";
+        static final String NEGATE_AMOUNT = "negateAmount";
+        static final String POSITION = "position";
+        static final String PREF_ACCOUNT = "preferredAccountsFilter";
+        static final String PROFILES = "profiles";
+        static final String REGEX = "regex";
+        static final String SHOW_COMMENTS = "showCommentsByDefault";
+        static final String SHOW_COMMODITY = "showCommodityByDefault";
+        static final String TEMPLATES = "templates";
+        static final String TEST_TEXT = "testText";
+        static final String TRANSACTION = "description";
+        static final String TRANSACTION_GROUP = "descriptionMatchGroup";
+        static final String URL = "url";
+        static final String USE_AUTH = "useAuth";
+        static final String UUID = "uuid";
+    }
+
+    abstract static public class OnErrorListener {
+        public abstract void error(Exception e);
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/backup/ConfigReader.java b/app/src/main/java/net/ktnx/mobileledger/backup/ConfigReader.java
new file mode 100644 (file)
index 0000000..187eb9b
--- /dev/null
@@ -0,0 +1,76 @@
+/*
+ * Copyright © 2022 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.backup;
+
+import android.content.Context;
+import android.net.Uri;
+
+import net.ktnx.mobileledger.dao.ProfileDAO;
+import net.ktnx.mobileledger.db.DB;
+import net.ktnx.mobileledger.db.Profile;
+import net.ktnx.mobileledger.model.Data;
+import net.ktnx.mobileledger.utils.Misc;
+
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+
+public class ConfigReader extends ConfigIO {
+    private final OnDoneListener onDoneListener;
+    private RawConfigReader r;
+    public ConfigReader(Context context, Uri uri, OnErrorListener onErrorListener,
+                        OnDoneListener onDoneListener) throws FileNotFoundException {
+        super(context, uri, onErrorListener);
+
+        this.onDoneListener = onDoneListener;
+    }
+    @Override
+    protected String getStreamMode() {
+        return "r";
+    }
+    @Override
+    protected void initStream() {
+        r = new RawConfigReader(new FileInputStream(pfd.getFileDescriptor()));
+    }
+    @Override
+    protected void processStream() throws IOException {
+        r.readConfig();
+        r.restoreAll();
+        String currentProfile = r.getCurrentProfile();
+
+        if (Data.getProfile() == null) {
+            Profile p = null;
+            final ProfileDAO dao = DB.get()
+                                     .getProfileDAO();
+            if (currentProfile != null)
+                p = dao.getByUuidSync(currentProfile);
+
+            if (p == null)
+                dao.getAnySync();
+
+            if (p != null)
+                Data.postCurrentProfile(p);
+        }
+
+        if (onDoneListener != null)
+            Misc.onMainThread(onDoneListener::done);
+    }
+    abstract static public class OnDoneListener {
+        public abstract void done();
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/backup/ConfigWriter.java b/app/src/main/java/net/ktnx/mobileledger/backup/ConfigWriter.java
new file mode 100644 (file)
index 0000000..b1a2814
--- /dev/null
@@ -0,0 +1,56 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.backup;
+
+import android.content.Context;
+import android.net.Uri;
+
+import net.ktnx.mobileledger.utils.Misc;
+
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+
+public class ConfigWriter extends ConfigIO {
+    private final OnDoneListener onDoneListener;
+    private RawConfigWriter w;
+    public ConfigWriter(Context context, Uri uri, OnErrorListener onErrorListener,
+                        OnDoneListener onDoneListener) throws FileNotFoundException {
+        super(context, uri, onErrorListener);
+
+        this.onDoneListener = onDoneListener;
+    }
+    @Override
+    protected String getStreamMode() {
+        return "w";
+    }
+    @Override
+    protected void initStream() {
+        w = new RawConfigWriter(new FileOutputStream(pfd.getFileDescriptor()));
+    }
+    @Override
+    protected void processStream() throws IOException {
+        w.writeConfig();
+
+        if (onDoneListener != null)
+            Misc.onMainThread(onDoneListener::done);
+    }
+    abstract static public class OnDoneListener {
+        public abstract void done();
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/backup/MobileLedgerBackupAgent.java b/app/src/main/java/net/ktnx/mobileledger/backup/MobileLedgerBackupAgent.java
new file mode 100644 (file)
index 0000000..6653843
--- /dev/null
@@ -0,0 +1,74 @@
+/*
+ * Copyright © 2022 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.backup;
+
+import android.app.backup.BackupAgent;
+import android.app.backup.BackupDataInput;
+import android.app.backup.BackupDataOutput;
+import android.os.ParcelFileDescriptor;
+
+import net.ktnx.mobileledger.db.DB;
+import net.ktnx.mobileledger.utils.Logger;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+
+public class MobileLedgerBackupAgent extends BackupAgent {
+    private static final int READ_BUF_LEN = 10;
+    public static String SETTINGS_KEY = "settings";
+    @Override
+    public void onBackup(ParcelFileDescriptor oldState, BackupDataOutput data,
+                         ParcelFileDescriptor newState) throws IOException {
+        Logger.debug("backup", "onBackup()");
+        backupSettings(data);
+        newState.close();
+    }
+    private void backupSettings(BackupDataOutput data) throws IOException {
+        Logger.debug ("backup", "Starting cloud backup");
+        ByteArrayOutputStream output = new ByteArrayOutputStream(4096);
+        RawConfigWriter saver = new RawConfigWriter(output);
+        saver.writeConfig();
+        byte[] bytes = output.toByteArray();
+        data.writeEntityHeader(SETTINGS_KEY, bytes.length);
+        data.writeEntityData(bytes, bytes.length);
+        Logger.debug("backup", "Done writing backup data");
+    }
+    @Override
+    public void onRestore(BackupDataInput data, int appVersionCode, ParcelFileDescriptor newState)
+            throws IOException {
+        Logger.debug("restore", "Starting cloud restore");
+        if (data.readNextHeader()) {
+            String key = data.getKey();
+            if (key.equals(SETTINGS_KEY)) {
+                restoreSettings(data);
+            }
+        }
+    }
+    private void restoreSettings(BackupDataInput data) throws IOException {
+        byte[] bytes = new byte[data.getDataSize()];
+        data.readEntityData(bytes, 0, bytes.length);
+        RawConfigReader reader = new RawConfigReader(new ByteArrayInputStream(bytes));
+        reader.readConfig();
+        Logger.debug("restore", "Successfully read restore data. Wiping database");
+        DB.get().deleteAllSync();
+        Logger.debug("restore", "Database wiped");
+        reader.restoreAll();
+        Logger.debug("restore", "All data restored from the cloud");
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/backup/RawConfigReader.java b/app/src/main/java/net/ktnx/mobileledger/backup/RawConfigReader.java
new file mode 100644 (file)
index 0000000..95b27b3
--- /dev/null
@@ -0,0 +1,401 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.backup;
+
+import android.util.JsonReader;
+import android.util.JsonToken;
+
+import net.ktnx.mobileledger.App;
+import net.ktnx.mobileledger.backup.ConfigIO.Keys;
+import net.ktnx.mobileledger.dao.CurrencyDAO;
+import net.ktnx.mobileledger.dao.ProfileDAO;
+import net.ktnx.mobileledger.dao.TemplateHeaderDAO;
+import net.ktnx.mobileledger.db.Currency;
+import net.ktnx.mobileledger.db.DB;
+import net.ktnx.mobileledger.db.Profile;
+import net.ktnx.mobileledger.db.TemplateAccount;
+import net.ktnx.mobileledger.db.TemplateHeader;
+import net.ktnx.mobileledger.db.TemplateWithAccounts;
+import net.ktnx.mobileledger.model.Data;
+import net.ktnx.mobileledger.utils.Logger;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.ArrayList;
+import java.util.List;
+
+public class RawConfigReader {
+    private final JsonReader r;
+    private List<Currency> commodities;
+    private List<Profile> profiles;
+    private List<TemplateWithAccounts> templates;
+    private String currentProfile;
+    public RawConfigReader(InputStream inputStream) {
+        r = new JsonReader(new BufferedReader(new InputStreamReader(inputStream)));
+    }
+    public List<Currency> getCommodities() {
+        return commodities;
+    }
+    public List<Profile> getProfiles() {
+        return profiles;
+    }
+    public List<TemplateWithAccounts> getTemplates() {
+        return templates;
+    }
+    public String getCurrentProfile() {
+        return currentProfile;
+    }
+    public void readConfig() throws IOException {
+        commodities = null;
+        profiles = null;
+        templates = null;
+        currentProfile = null;
+        r.beginObject();
+        while (r.hasNext()) {
+            String item = r.nextName();
+            if (r.peek() == JsonToken.NULL) {
+                r.nextNull();
+                continue;
+            }
+            switch (item) {
+                case Keys.COMMODITIES:
+                    commodities = readCommodities();
+                    break;
+                case Keys.PROFILES:
+                    profiles = readProfiles();
+                    break;
+                case Keys.TEMPLATES:
+                    templates = readTemplates();
+                    break;
+                case Keys.CURRENT_PROFILE:
+                    currentProfile = r.nextString();
+                    break;
+                default:
+                    throw new RuntimeException("unexpected top-level item " + item);
+            }
+        }
+        r.endObject();
+    }
+    private TemplateAccount readTemplateAccount() throws IOException {
+        r.beginObject();
+        TemplateAccount result = new TemplateAccount(0L, 0L, 0L);
+        while (r.peek() != JsonToken.END_OBJECT) {
+            String item = r.nextName();
+            if (r.peek() == JsonToken.NULL) {
+                r.nextNull();
+                continue;
+            }
+            switch (item) {
+                case Keys.NAME:
+                    result.setAccountName(r.nextString());
+                    break;
+                case Keys.NAME_GROUP:
+                    result.setAccountNameMatchGroup(r.nextInt());
+                    break;
+                case Keys.COMMENT:
+                    result.setAccountComment(r.nextString());
+                    break;
+                case Keys.COMMENT_GROUP:
+                    result.setAccountCommentMatchGroup(r.nextInt());
+                    break;
+                case Keys.AMOUNT:
+                    result.setAmount((float) r.nextDouble());
+                    break;
+                case Keys.AMOUNT_GROUP:
+                    result.setAmountMatchGroup(r.nextInt());
+                    break;
+                case Keys.NEGATE_AMOUNT:
+                    result.setNegateAmount(r.nextBoolean());
+                    break;
+                case Keys.CURRENCY:
+                    result.setCurrency(r.nextLong());
+                    break;
+                case Keys.CURRENCY_GROUP:
+                    result.setCurrencyMatchGroup(r.nextInt());
+                    break;
+
+                default:
+                    throw new IllegalStateException("Unexpected template account item: " + item);
+            }
+        }
+        r.endObject();
+
+        return result;
+    }
+    private TemplateWithAccounts readTemplate(JsonReader r) throws IOException {
+        r.beginObject();
+        String name = null;
+        TemplateHeader t = new TemplateHeader(0L, "", "");
+        List<TemplateAccount> accounts = new ArrayList<>();
+
+        while (r.peek() != JsonToken.END_OBJECT) {
+            String item = r.nextName();
+            if (r.peek() == JsonToken.NULL) {
+                r.nextNull();
+                continue;
+            }
+            switch (item) {
+                case Keys.UUID:
+                    t.setUuid(r.nextString());
+                    break;
+                case Keys.NAME:
+                    t.setName(r.nextString());
+                    break;
+                case Keys.REGEX:
+                    t.setRegularExpression(r.nextString());
+                    break;
+                case Keys.TEST_TEXT:
+                    t.setTestText(r.nextString());
+                    break;
+                case Keys.DATE_YEAR:
+                    t.setDateYear(r.nextInt());
+                    break;
+                case Keys.DATE_YEAR_GROUP:
+                    t.setDateYearMatchGroup(r.nextInt());
+                    break;
+                case Keys.DATE_MONTH:
+                    t.setDateMonth(r.nextInt());
+                    break;
+                case Keys.DATE_MONTH_GROUP:
+                    t.setDateMonthMatchGroup(r.nextInt());
+                    break;
+                case Keys.DATE_DAY:
+                    t.setDateDay(r.nextInt());
+                    break;
+                case Keys.DATE_DAY_GROUP:
+                    t.setDateDayMatchGroup(r.nextInt());
+                    break;
+                case Keys.TRANSACTION:
+                    t.setTransactionDescription(r.nextString());
+                    break;
+                case Keys.TRANSACTION_GROUP:
+                    t.setTransactionDescriptionMatchGroup(r.nextInt());
+                    break;
+                case Keys.COMMENT:
+                    t.setTransactionComment(r.nextString());
+                    break;
+                case Keys.COMMENT_GROUP:
+                    t.setTransactionCommentMatchGroup(r.nextInt());
+                    break;
+                case Keys.IS_FALLBACK:
+                    t.setFallback(r.nextBoolean());
+                    break;
+                case Keys.ACCOUNTS:
+                    r.beginArray();
+                    while (r.peek() == JsonToken.BEGIN_OBJECT) {
+                        accounts.add(readTemplateAccount());
+                    }
+                    r.endArray();
+                    break;
+                default:
+                    throw new RuntimeException("Unknown template header item: " + item);
+            }
+        }
+        r.endObject();
+
+        TemplateWithAccounts result = new TemplateWithAccounts();
+        result.header = t;
+        result.accounts = accounts;
+        return result;
+    }
+    private List<TemplateWithAccounts> readTemplates() throws IOException {
+        List<TemplateWithAccounts> list = new ArrayList<>();
+
+        r.beginArray();
+        while (r.peek() == JsonToken.BEGIN_OBJECT) {
+            list.add(readTemplate(r));
+        }
+        r.endArray();
+
+        return list;
+    }
+    private List<Currency> readCommodities() throws IOException {
+        List<Currency> list = new ArrayList<>();
+
+        r.beginArray();
+        while (r.peek() == JsonToken.BEGIN_OBJECT) {
+            Currency c = new Currency();
+
+            r.beginObject();
+            while (r.peek() != JsonToken.END_OBJECT) {
+                final String item = r.nextName();
+                if (r.peek() == JsonToken.NULL) {
+                    r.nextNull();
+                    continue;
+                }
+                switch (item) {
+                    case Keys.NAME:
+                        c.setName(r.nextString());
+                        break;
+                    case Keys.POSITION:
+                        c.setPosition(r.nextString());
+                        break;
+                    case Keys.HAS_GAP:
+                        c.setHasGap(r.nextBoolean());
+                        break;
+                    default:
+                        throw new RuntimeException("Unknown commodity key: " + item);
+                }
+            }
+            r.endObject();
+
+            if (c.getName()
+                 .isEmpty())
+                throw new RuntimeException("Missing commodity name");
+
+            list.add(c);
+        }
+        r.endArray();
+
+        return list;
+    }
+    private List<Profile> readProfiles() throws IOException {
+        List<Profile> list = new ArrayList<>();
+        r.beginArray();
+        while (r.peek() == JsonToken.BEGIN_OBJECT) {
+            Profile p = new Profile();
+            r.beginObject();
+            while (r.peek() != JsonToken.END_OBJECT) {
+                String item = r.nextName();
+                if (r.peek() == JsonToken.NULL) {
+                    r.nextNull();
+                    continue;
+                }
+
+                switch (item) {
+                    case Keys.UUID:
+                        p.setUuid(r.nextString());
+                        break;
+                    case Keys.NAME:
+                        p.setName(r.nextString());
+                        break;
+                    case Keys.URL:
+                        p.setUrl(r.nextString());
+                        break;
+                    case Keys.USE_AUTH:
+                        p.setUseAuthentication(r.nextBoolean());
+                        break;
+                    case Keys.AUTH_USER:
+                        p.setAuthUser(r.nextString());
+                        break;
+                    case Keys.AUTH_PASS:
+                        p.setAuthPassword(r.nextString());
+                        break;
+                    case Keys.API_VER:
+                        p.setApiVersion(r.nextInt());
+                        break;
+                    case Keys.CAN_POST:
+                        p.setPermitPosting(r.nextBoolean());
+                        break;
+                    case Keys.DEFAULT_COMMODITY:
+                        p.setDefaultCommodity(r.nextString());
+                        break;
+                    case Keys.SHOW_COMMODITY:
+                        p.setShowCommodityByDefault(r.nextBoolean());
+                        break;
+                    case Keys.SHOW_COMMENTS:
+                        p.setShowCommentsByDefault(r.nextBoolean());
+                        break;
+                    case Keys.FUTURE_DATES:
+                        p.setFutureDates(r.nextInt());
+                        break;
+                    case Keys.PREF_ACCOUNT:
+                        p.setPreferredAccountsFilter(r.nextString());
+                        break;
+                    case Keys.COLOUR:
+                        p.setTheme(r.nextInt());
+                        break;
+
+
+                    default:
+                        throw new IllegalStateException("Unexpected profile item: " + item);
+                }
+            }
+            r.endObject();
+
+            list.add(p);
+        }
+        r.endArray();
+
+        return list;
+    }
+    public void restoreAll() {
+        restoreCommodities();
+        restoreProfiles();
+        restoreTemplates();
+        restoreCurrentProfile();
+    }
+    private void restoreTemplates() {
+        if (templates == null)
+            return;
+
+        TemplateHeaderDAO dao = DB.get()
+                                  .getTemplateDAO();
+
+        for (TemplateWithAccounts t : templates) {
+            if (dao.getTemplateWithAccountsByUuidSync(t.header.getUuid()) == null)
+                dao.insertSync(t);
+        }
+    }
+    private void restoreProfiles() {
+        if (profiles == null)
+            return;
+
+        ProfileDAO dao = DB.get()
+                           .getProfileDAO();
+
+        for (Profile p : profiles) {
+            if (dao.getByUuidSync(p.getUuid()) == null)
+                dao.insert(p);
+        }
+    }
+    private void restoreCommodities() {
+        if (commodities == null)
+            return;
+
+        CurrencyDAO dao = DB.get()
+                            .getCurrencyDAO();
+
+        for (Currency c : commodities) {
+            if (dao.getByNameSync(c.getName()) == null)
+                dao.insert(c);
+        }
+    }
+    private void restoreCurrentProfile() {
+        if (currentProfile == null) {
+            Logger.debug("backup", "Not restoring current profile (not present in backup)");
+            return;
+        }
+
+        ProfileDAO dao = DB.get()
+                           .getProfileDAO();
+
+        Profile p = dao.getByUuidSync(currentProfile);
+
+        if (p != null) {
+            Logger.debug("backup", "Restoring current profile "+p.getName());
+            Data.postCurrentProfile(p);
+            App.storeStartupProfileAndTheme(p.getId(), p.getTheme());
+        }
+        else {
+            Logger.debug("backup", "Not restoring profile "+currentProfile+": not found in DB");
+        }
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/backup/RawConfigWriter.java b/app/src/main/java/net/ktnx/mobileledger/backup/RawConfigWriter.java
new file mode 100644 (file)
index 0000000..751a74a
--- /dev/null
@@ -0,0 +1,212 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.backup;
+
+import android.util.JsonWriter;
+
+import net.ktnx.mobileledger.backup.ConfigIO.Keys;
+import net.ktnx.mobileledger.db.Currency;
+import net.ktnx.mobileledger.db.DB;
+import net.ktnx.mobileledger.db.Profile;
+import net.ktnx.mobileledger.db.TemplateAccount;
+import net.ktnx.mobileledger.db.TemplateWithAccounts;
+import net.ktnx.mobileledger.json.API;
+import net.ktnx.mobileledger.model.Data;
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.util.List;
+
+public class RawConfigWriter {
+    private final JsonWriter w;
+    public RawConfigWriter(OutputStream outputStream) {
+        w = new JsonWriter(new BufferedWriter(new OutputStreamWriter(outputStream)));
+        w.setIndent("  ");
+    }
+    public void writeConfig() throws IOException {
+        w.beginObject();
+        writeCommodities();
+        writeProfiles();
+        writeCurrentProfile();
+        writeConfigTemplates();
+        w.endObject();
+        w.flush();
+    }
+    private void writeKey(String key, String value) throws IOException {
+        if (value != null)
+            w.name(key)
+             .value(value);
+    }
+    private void writeKey(String key, Integer value) throws IOException {
+        if (value != null)
+            w.name(key)
+             .value(value);
+    }
+    private void writeKey(String key, Long value) throws IOException {
+        if (value != null)
+            w.name(key)
+             .value(value);
+    }
+    private void writeKey(String key, Float value) throws IOException {
+        if (value != null)
+            w.name(key)
+             .value(value);
+    }
+    private void writeKey(String key, Boolean value) throws IOException {
+        if (value != null)
+            w.name(key)
+             .value(value);
+    }
+    private void writeConfigTemplates() throws IOException {
+        List<TemplateWithAccounts> templates = DB.get()
+                                                 .getTemplateDAO()
+                                                 .getAllTemplatesWithAccountsSync();
+
+        if (templates.isEmpty())
+            return;
+
+        w.name("templates")
+         .beginArray();
+        for (TemplateWithAccounts t : templates) {
+            w.beginObject();
+
+            w.name(Keys.UUID)
+             .value(t.header.getUuid());
+            w.name(Keys.NAME)
+             .value(t.header.getName());
+            w.name(Keys.REGEX)
+             .value(t.header.getRegularExpression());
+            writeKey(Keys.TEST_TEXT, t.header.getTestText());
+            writeKey(ConfigIO.Keys.DATE_YEAR, t.header.getDateYear());
+            writeKey(Keys.DATE_YEAR_GROUP, t.header.getDateYearMatchGroup());
+            writeKey(Keys.DATE_MONTH, t.header.getDateMonth());
+            writeKey(Keys.DATE_MONTH_GROUP, t.header.getDateMonthMatchGroup());
+            writeKey(Keys.DATE_DAY, t.header.getDateDay());
+            writeKey(Keys.DATE_DAY_GROUP, t.header.getDateDayMatchGroup());
+            writeKey(Keys.TRANSACTION, t.header.getTransactionDescription());
+            writeKey(Keys.TRANSACTION_GROUP, t.header.getTransactionDescriptionMatchGroup());
+            writeKey(Keys.COMMENT, t.header.getTransactionComment());
+            writeKey(Keys.COMMENT_GROUP, t.header.getTransactionCommentMatchGroup());
+            w.name(Keys.IS_FALLBACK)
+             .value(t.header.isFallback());
+            if (t.accounts.size() > 0) {
+                w.name(Keys.ACCOUNTS)
+                 .beginArray();
+                for (TemplateAccount a : t.accounts) {
+                    w.beginObject();
+
+                    writeKey(Keys.NAME, a.getAccountName());
+                    writeKey(Keys.NAME_GROUP, a.getAccountNameMatchGroup());
+                    writeKey(Keys.COMMENT, a.getAccountComment());
+                    writeKey(Keys.COMMENT_GROUP, a.getAccountCommentMatchGroup());
+                    writeKey(Keys.AMOUNT, a.getAmount());
+                    writeKey(Keys.AMOUNT_GROUP, a.getAmountMatchGroup());
+                    writeKey(Keys.NEGATE_AMOUNT, a.getNegateAmount());
+                    writeKey(Keys.CURRENCY, a.getCurrency());
+                    writeKey(Keys.CURRENCY_GROUP, a.getCurrencyMatchGroup());
+
+                    w.endObject();
+                }
+                w.endArray();
+            }
+
+            w.endObject();
+        }
+        w.endArray();
+    }
+    private void writeCommodities() throws IOException {
+        List<Currency> list = DB.get()
+                                .getCurrencyDAO()
+                                .getAllSync();
+        if (list.isEmpty())
+            return;
+        w.name(Keys.COMMODITIES)
+         .beginArray();
+        for (Currency c : list) {
+            w.beginObject();
+            writeKey(Keys.NAME, c.getName());
+            writeKey(Keys.POSITION, c.getPosition());
+            writeKey(Keys.HAS_GAP, c.getHasGap());
+            w.endObject();
+        }
+        w.endArray();
+    }
+    private void writeProfiles() throws IOException {
+        List<Profile> profiles = DB.get()
+                                   .getProfileDAO()
+                                   .getAllOrderedSync();
+
+        if (profiles.isEmpty())
+            return;
+
+        w.name(Keys.PROFILES)
+         .beginArray();
+        for (Profile p : profiles) {
+            w.beginObject();
+
+            w.name(Keys.NAME)
+             .value(p.getName());
+            w.name(Keys.UUID)
+             .value(p.getUuid());
+            w.name(Keys.URL)
+             .value(p.getUrl());
+            w.name(Keys.USE_AUTH)
+             .value(p.useAuthentication());
+            if (p.useAuthentication()) {
+                w.name(Keys.AUTH_USER)
+                 .value(p.getAuthUser());
+                w.name(Keys.AUTH_PASS)
+                 .value(p.getAuthPassword());
+            }
+            if (p.getApiVersion() != API.auto.toInt())
+                w.name(Keys.API_VER)
+                 .value(p.getApiVersion());
+            w.name(Keys.CAN_POST)
+             .value(p.permitPosting());
+            if (p.permitPosting()) {
+                String defaultCommodity = p.getDefaultCommodity();
+                if (!defaultCommodity.isEmpty())
+                    w.name(Keys.DEFAULT_COMMODITY)
+                     .value(defaultCommodity);
+                w.name(Keys.SHOW_COMMODITY)
+                 .value(p.getShowCommodityByDefault());
+                w.name(Keys.SHOW_COMMENTS)
+                 .value(p.getShowCommentsByDefault());
+                w.name(Keys.FUTURE_DATES)
+                 .value(p.getFutureDates());
+                w.name(Keys.PREF_ACCOUNT)
+                 .value(p.getPreferredAccountsFilter());
+            }
+            w.name(Keys.COLOUR)
+             .value(p.getTheme());
+
+            w.endObject();
+        }
+        w.endArray();
+    }
+    private void writeCurrentProfile() throws IOException {
+        Profile currentProfile = Data.getProfile();
+        if (currentProfile == null)
+            return;
+
+        w.name(Keys.CURRENT_PROFILE)
+         .value(currentProfile.getUuid());
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/dao/AccountDAO.java b/app/src/main/java/net/ktnx/mobileledger/dao/AccountDAO.java
new file mode 100644 (file)
index 0000000..5ad4c90
--- /dev/null
@@ -0,0 +1,203 @@
+/*
+ * Copyright © 2024 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.dao;
+
+import androidx.annotation.NonNull;
+import androidx.lifecycle.LiveData;
+import androidx.room.ColumnInfo;
+import androidx.room.Dao;
+import androidx.room.Delete;
+import androidx.room.Insert;
+import androidx.room.OnConflictStrategy;
+import androidx.room.Query;
+import androidx.room.Transaction;
+import androidx.room.Update;
+
+import net.ktnx.mobileledger.db.Account;
+import net.ktnx.mobileledger.db.AccountValue;
+import net.ktnx.mobileledger.db.AccountWithAmounts;
+import net.ktnx.mobileledger.db.DB;
+
+import java.util.ArrayList;
+import java.util.List;
+
+
+@Dao
+public abstract class AccountDAO extends BaseDAO<Account> {
+    static public List<String> unbox(List<AccountNameContainer> list) {
+        ArrayList<String> result = new ArrayList<>(list.size());
+        for (AccountNameContainer item : list) {
+            result.add(item.name);
+        }
+
+        return result;
+    }
+
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    public abstract long insertSync(Account item);
+
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    public abstract void insertSync(List<Account> items);
+
+    @Transaction
+    public void insertSync(@NonNull AccountWithAmounts accountWithAmounts) {
+        final AccountValueDAO valueDAO = DB.get()
+                                           .getAccountValueDAO();
+        Account account = accountWithAmounts.account;
+        account.setId(insertSync(account));
+        for (AccountValue value : accountWithAmounts.amounts) {
+            value.setAccountId(account.getId());
+            value.setGeneration(account.getGeneration());
+            value.setId(valueDAO.insertSync(value));
+        }
+    }
+    @Update
+    public abstract void updateSync(Account item);
+
+    @Delete
+    public abstract void deleteSync(Account item);
+
+    @Delete
+    public abstract void deleteSync(List<Account> items);
+
+    @Query("DELETE FROM accounts")
+    public abstract void deleteAllSync();
+
+    @Query("SELECT * FROM accounts WHERE profile_id=:profileId AND IIF(:includeZeroBalances=1, 1," +
+           " (EXISTS(SELECT 1 FROM account_values av WHERE av.account_id=accounts.id AND av.value" +
+           " <> 0) OR EXISTS(SELECT 1 FROM accounts a WHERE a.parent_name = accounts.name))) " +
+           "ORDER BY name")
+    public abstract LiveData<List<Account>> getAll(long profileId, boolean includeZeroBalances);
+
+    @Transaction
+    @Query("SELECT * FROM accounts WHERE profile_id = :profileId AND IIF(:includeZeroBalances=1, " +
+           "1, (EXISTS(SELECT 1 FROM account_values av WHERE av.account_id=accounts.id AND av" +
+           ".value <> 0) OR EXISTS(SELECT 1 FROM accounts a WHERE a.parent_name = accounts.name))" +
+           ") ORDER BY name")
+    public abstract LiveData<List<AccountWithAmounts>> getAllWithAmounts(long profileId,
+                                                                         boolean includeZeroBalances);
+
+    @Query("SELECT * FROM accounts WHERE id=:id")
+    public abstract Account getByIdSync(long id);
+
+    //    not useful for now
+//    @Transaction
+//    @Query("SELECT * FROM patterns")
+//    List<PatternWithAccounts> getPatternsWithAccounts();
+    @Query("SELECT * FROM accounts WHERE profile_id = :profileId AND name = :accountName")
+    public abstract LiveData<Account> getByName(long profileId, @NonNull String accountName);
+
+    @Query("SELECT * FROM accounts WHERE profile_id = :profileId AND name = :accountName")
+    public abstract Account getByNameSync(long profileId, @NonNull String accountName);
+
+    @Transaction
+    @Query("SELECT * FROM accounts WHERE profile_id = :profileId AND name = :accountName")
+    public abstract LiveData<AccountWithAmounts> getByNameWithAmounts(long profileId,
+                                                                      @NonNull String accountName);
+
+    @Query("SELECT name, CASE WHEN name_upper LIKE :term||'%%' THEN 1 " +
+           "               WHEN name_upper LIKE '%%:'||:term||'%%' THEN 2 " +
+           "               WHEN name_upper LIKE '%% '||:term||'%%' THEN 3 " +
+           "               ELSE 9 END AS ordering " + "FROM accounts " +
+           "WHERE profile_id=:profileId AND name_upper LIKE '%%'||:term||'%%' " +
+           "ORDER BY ordering, name_upper, rowid ")
+    public abstract LiveData<List<AccountNameContainer>> lookupNamesInProfileByName(long profileId,
+                                                                                    @NonNull
+                                                                                            String term);
+
+    @Query("SELECT name, CASE WHEN name_upper LIKE :term||'%%' THEN 1 " +
+           "               WHEN name_upper LIKE '%%:'||:term||'%%' THEN 2 " +
+           "               WHEN name_upper LIKE '%% '||:term||'%%' THEN 3 " +
+           "               ELSE 9 END AS ordering " + "FROM accounts " +
+           "WHERE profile_id=:profileId AND name_upper LIKE '%%'||:term||'%%' " +
+           "ORDER BY ordering, name_upper, rowid ")
+    public abstract List<AccountNameContainer> lookupNamesInProfileByNameSync(long profileId,
+                                                                              @NonNull String term);
+
+    @Transaction
+    @Query("SELECT * FROM accounts " +
+           "WHERE profile_id=:profileId AND name_upper LIKE '%%'||:term||'%%' " +
+           "ORDER BY  CASE WHEN name_upper LIKE :term||'%%' THEN 1 " +
+           "               WHEN name_upper LIKE '%%:'||:term||'%%' THEN 2 " +
+           "               WHEN name_upper LIKE '%% '||:term||'%%' THEN 3 " +
+           "               ELSE 9 END, name_upper, rowid ")
+    public abstract List<AccountWithAmounts> lookupWithAmountsInProfileByNameSync(long profileId,
+                                                                                  @NonNull String term);
+
+    @Query("SELECT DISTINCT name, CASE WHEN name_upper LIKE :term||'%%' THEN 1 " +
+           "               WHEN name_upper LIKE '%%:'||:term||'%%' THEN 2 " +
+           "               WHEN name_upper LIKE '%% '||:term||'%%' THEN 3 " +
+           "               ELSE 9 END AS ordering " + "FROM accounts " +
+           "WHERE name_upper LIKE '%%'||:term||'%%' " + "ORDER BY ordering, name_upper, rowid ")
+    public abstract LiveData<List<AccountNameContainer>> lookupNamesByName(@NonNull String term);
+
+    @Query("SELECT DISTINCT name, CASE WHEN name_upper LIKE :term||'%%' THEN 1 " +
+           "               WHEN name_upper LIKE '%%:'||:term||'%%' THEN 2 " +
+           "               WHEN name_upper LIKE '%% '||:term||'%%' THEN 3 " +
+           "               ELSE 9 END AS ordering " + "FROM accounts " +
+           "WHERE name_upper LIKE '%%'||:term||'%%' " + "ORDER BY ordering, name_upper, rowid ")
+    public abstract List<AccountNameContainer> lookupNamesByNameSync(@NonNull String term);
+
+    @Query("SELECT * FROM accounts WHERE profile_id = :profileId")
+    public abstract List<Account> allForProfileSync(long profileId);
+
+    @Query("SELECT generation FROM accounts WHERE profile_id = :profileId LIMIT 1")
+    protected abstract AccountGenerationContainer getGenerationPOJOSync(long profileId);
+    public long getGenerationSync(long profileId) {
+        AccountGenerationContainer result = getGenerationPOJOSync(profileId);
+
+        if (result == null)
+            return 0;
+        return result.generation;
+    }
+    @Query("DELETE FROM accounts WHERE profile_id = :profileId AND generation <> " +
+           ":currentGeneration")
+    public abstract void purgeOldAccountsSync(long profileId, long currentGeneration);
+
+    @Query("DELETE FROM account_values WHERE EXISTS (SELECT 1 FROM accounts a WHERE a" +
+           ".id=account_values.account_id AND a.profile_id=:profileId) AND generation <> " +
+           ":currentGeneration")
+    public abstract void purgeOldAccountValuesSync(long profileId, long currentGeneration);
+    @Transaction
+    public void storeAccountsSync(List<AccountWithAmounts> accounts, long profileId) {
+        long generation = getGenerationSync(profileId) + 1;
+
+        for (AccountWithAmounts rec : accounts) {
+            rec.account.setGeneration(generation);
+            rec.account.setProfileId(profileId);
+            insertSync(rec);
+        }
+        purgeOldAccountsSync(profileId, generation);
+        purgeOldAccountValuesSync(profileId, generation);
+    }
+
+    static public class AccountNameContainer {
+        @ColumnInfo
+        public String name;
+        @ColumnInfo
+        public int ordering;
+    }
+
+    static class AccountGenerationContainer {
+        @ColumnInfo
+        long generation;
+        public AccountGenerationContainer(long generation) {
+            this.generation = generation;
+        }
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/dao/AccountValueDAO.java b/app/src/main/java/net/ktnx/mobileledger/dao/AccountValueDAO.java
new file mode 100644 (file)
index 0000000..8a7e5fb
--- /dev/null
@@ -0,0 +1,52 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.dao;
+
+import androidx.annotation.NonNull;
+import androidx.lifecycle.LiveData;
+import androidx.room.Dao;
+import androidx.room.Delete;
+import androidx.room.Insert;
+import androidx.room.OnConflictStrategy;
+import androidx.room.Query;
+import androidx.room.Update;
+
+import net.ktnx.mobileledger.db.AccountValue;
+
+import java.util.List;
+
+@Dao
+public abstract class AccountValueDAO extends BaseDAO<AccountValue> {
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    public abstract long insertSync(AccountValue item);
+
+    @Update
+    public abstract void updateSync(AccountValue item);
+
+    @Delete
+    public abstract void deleteSync(AccountValue item);
+
+    @Query("DELETE FROM account_values")
+    public abstract void deleteAllSync();
+
+    @Query("SELECT * FROM account_values WHERE account_id=:accountId")
+    public abstract LiveData<List<AccountValue>> getAll(long accountId);
+
+    @Query("SELECT * FROM account_values WHERE account_id = :accountId AND currency = :currency")
+    public abstract AccountValue getByCurrencySync(long accountId, @NonNull String currency);
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/dao/AsyncResultCallback.java b/app/src/main/java/net/ktnx/mobileledger/dao/AsyncResultCallback.java
new file mode 100644 (file)
index 0000000..df607ae
--- /dev/null
@@ -0,0 +1,22 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.dao;
+
+public interface AsyncResultCallback<T extends Object> {
+    void onResult(T result);
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/dao/BaseDAO.java b/app/src/main/java/net/ktnx/mobileledger/dao/BaseDAO.java
new file mode 100644 (file)
index 0000000..905bf23
--- /dev/null
@@ -0,0 +1,66 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.dao;
+
+import androidx.annotation.NonNull;
+
+import net.ktnx.mobileledger.utils.Misc;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+
+public abstract class BaseDAO<T> {
+    private final static Executor asyncRunner = Executors.newSingleThreadExecutor();
+    public static void runAsync(Runnable runnable) {
+        asyncRunner.execute(runnable);
+    }
+    abstract long insertSync(T item);
+    public void insert(T item) {
+        asyncRunner.execute(() -> insertSync(item));
+    }
+    public void insert(T item, @NonNull OnInsertedReceiver receiver) {
+        asyncRunner.execute(() -> {
+            long id = insertSync(item);
+            Misc.onMainThread(() -> receiver.onInsert(id));
+        });
+    }
+
+    abstract void updateSync(T item);
+    public void update(T item) {
+        asyncRunner.execute(() -> updateSync(item));
+    }
+    public void update(T item, @NonNull Runnable onDone) {
+        asyncRunner.execute(() -> {
+            updateSync(item);
+            Misc.onMainThread(onDone);
+        });
+    }
+    abstract void deleteSync(T item);
+    public void delete(T item) {
+        asyncRunner.execute(() -> deleteSync(item));
+    }
+    public void delete(T item, @NonNull Runnable onDone) {
+        asyncRunner.execute(() -> {
+            deleteSync(item);
+            Misc.onMainThread(onDone);
+        });
+    }
+    interface OnInsertedReceiver {
+        void onInsert(long id);
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/dao/CurrencyDAO.java b/app/src/main/java/net/ktnx/mobileledger/dao/CurrencyDAO.java
new file mode 100644 (file)
index 0000000..1505a8f
--- /dev/null
@@ -0,0 +1,68 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.dao;
+
+import androidx.lifecycle.LiveData;
+import androidx.room.Dao;
+import androidx.room.Delete;
+import androidx.room.Insert;
+import androidx.room.OnConflictStrategy;
+import androidx.room.Query;
+import androidx.room.Update;
+
+import net.ktnx.mobileledger.db.Currency;
+
+import java.util.List;
+
+@Dao
+public abstract class CurrencyDAO extends BaseDAO<Currency> {
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    abstract long insertSync(Currency item);
+
+    @Update
+    abstract void updateSync(Currency item);
+
+    @Delete
+    public abstract void deleteSync(Currency item);
+
+    @Query("DELETE FROM currencies")
+    public abstract void deleteAllSync();
+
+    @Query("SELECT * FROM currencies")
+    public abstract LiveData<List<Currency>> getAll();
+
+    @Query("SELECT * FROM currencies")
+    public abstract List<Currency> getAllSync();
+
+    @Query("SELECT * FROM currencies WHERE id = :id")
+    abstract LiveData<Currency> getById(long id);
+
+    @Query("SELECT * FROM currencies WHERE id = :id")
+    public abstract Currency getByIdSync(long id);
+
+    @Query("SELECT * FROM currencies WHERE name = :name")
+    public abstract LiveData<Currency> getByName(String name);
+
+    @Query("SELECT * FROM currencies WHERE name = :name")
+    public abstract Currency getByNameSync(String name);
+
+//    not useful for now
+//    @Transaction
+//    @Query("SELECT * FROM patterns")
+//    List<PatternWithAccounts> getPatternsWithAccounts();
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/dao/OptionDAO.java b/app/src/main/java/net/ktnx/mobileledger/dao/OptionDAO.java
new file mode 100644 (file)
index 0000000..41d6464
--- /dev/null
@@ -0,0 +1,57 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.dao;
+
+import androidx.lifecycle.LiveData;
+import androidx.room.Dao;
+import androidx.room.Delete;
+import androidx.room.Insert;
+import androidx.room.OnConflictStrategy;
+import androidx.room.Query;
+import androidx.room.Update;
+
+import net.ktnx.mobileledger.db.Option;
+
+import java.util.List;
+
+@Dao
+public abstract class OptionDAO extends BaseDAO<Option> {
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    public abstract long insertSync(Option item);
+
+    @Update
+    public abstract void updateSync(Option item);
+
+    @Delete
+    public abstract void deleteSync(Option item);
+
+    @Delete
+    public abstract void deleteSync(List<Option> items);
+
+    @Query("DELETE from options")
+    public abstract void deleteAllSync();
+
+    @Query("SELECT * FROM options WHERE profile_id = :profileId AND name = :name")
+    public abstract LiveData<Option> load(long profileId, String name);
+
+    @Query("SELECT * FROM options WHERE profile_id = :profileId AND name = :name")
+    public abstract Option loadSync(long profileId, String name);
+
+    @Query("SELECT * FROM options WHERE profile_id = :profileId")
+    public abstract List<Option> allForProfileSync(long profileId);
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/dao/ProfileDAO.java b/app/src/main/java/net/ktnx/mobileledger/dao/ProfileDAO.java
new file mode 100644 (file)
index 0000000..c7a2e5d
--- /dev/null
@@ -0,0 +1,101 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.dao;
+
+import androidx.lifecycle.LiveData;
+import androidx.room.Dao;
+import androidx.room.Delete;
+import androidx.room.Insert;
+import androidx.room.OnConflictStrategy;
+import androidx.room.Query;
+import androidx.room.Transaction;
+import androidx.room.Update;
+
+import net.ktnx.mobileledger.db.Profile;
+
+import java.util.List;
+
+@Dao
+public abstract class ProfileDAO extends BaseDAO<Profile> {
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    abstract long insertSync(Profile item);
+
+    @Transaction
+    public long insertLastSync(Profile item) {
+        int count = getProfileCountSync();
+        item.setOrderNo(count + 1);
+        return insertSync(item);
+    }
+    public void insertLast(Profile item, OnInsertedReceiver onInsertedReceiver) {
+        BaseDAO.runAsync(() -> {
+            long id = insertLastSync(item);
+            if (onInsertedReceiver != null)
+                onInsertedReceiver.onInsert(id);
+        });
+    }
+
+    @Update
+    abstract void updateSync(Profile item);
+
+    @Delete
+    public abstract void deleteSync(Profile item);
+
+    @Query("DELETE FROM profiles")
+    public abstract void deleteAllSync();
+
+    @Query("select * from profiles where id = :profileId")
+    public abstract Profile getByIdSync(long profileId);
+
+    @Query("SELECT * FROM profiles WHERE id=:profileId")
+    public abstract LiveData<Profile> getById(long profileId);
+
+    @Query("SELECT * FROM profiles ORDER BY order_no")
+    public abstract List<Profile> getAllOrderedSync();
+
+    @Query("SELECT * FROM profiles ORDER BY order_no")
+    public abstract LiveData<List<Profile>> getAllOrdered();
+
+    @Query("SELECT * FROM profiles LIMIT 1")
+    public abstract Profile getAnySync();
+
+    @Query("SELECT * FROM profiles WHERE uuid=:uuid")
+    public abstract LiveData<Profile> getByUuid(String uuid);
+
+    @Query("SELECT * FROM profiles WHERE uuid=:uuid")
+    public abstract Profile getByUuidSync(String uuid);
+
+    @Query("SELECT MAX(order_no) FROM profiles")
+    public abstract int getProfileCountSync();
+    public void updateOrderSync(List<Profile> list) {
+        if (list == null)
+            list = getAllOrderedSync();
+        int order = 1;
+        for (Profile p : list) {
+            p.setOrderNo(order++);
+            updateSync(p);
+        }
+    }
+    public void updateOrder(List<Profile> list, Runnable onDone) {
+        BaseDAO.runAsync(() -> {
+            updateOrderSync(list);
+            if (onDone != null)
+                onDone.run();
+
+        });
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/dao/TemplateAccountDAO.java b/app/src/main/java/net/ktnx/mobileledger/dao/TemplateAccountDAO.java
new file mode 100644 (file)
index 0000000..c2be8c7
--- /dev/null
@@ -0,0 +1,57 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.dao;
+
+import androidx.annotation.NonNull;
+import androidx.lifecycle.LiveData;
+import androidx.room.Dao;
+import androidx.room.Delete;
+import androidx.room.Insert;
+import androidx.room.Query;
+import androidx.room.Update;
+
+import net.ktnx.mobileledger.db.TemplateAccount;
+
+import java.util.List;
+
+@Dao
+public interface TemplateAccountDAO {
+    @Insert
+    Long insertSync(TemplateAccount item);
+
+    @Update
+    void updateSync(TemplateAccount... items);
+
+    @Delete
+    void deleteSync(TemplateAccount item);
+
+    @Query("DELETE FROM template_accounts")
+    void deleteAllSync();
+
+    @Query("SELECT * FROM template_accounts WHERE template_id=:template_id")
+    LiveData<List<TemplateAccount>> getTemplateAccounts(Long template_id);
+
+    @Query("SELECT * FROM template_accounts WHERE id = :id")
+    LiveData<TemplateAccount> getPatternAccountById(Long id);
+
+    @Query("UPDATE template_accounts set position=-1 WHERE template_id=:templateId")
+    void prepareForSave(@NonNull Long templateId);
+
+    @Query("DELETE FROM template_accounts WHERE position=-1 AND template_id=:templateId")
+    void finishSave(@NonNull Long templateId);
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/dao/TemplateHeaderDAO.java b/app/src/main/java/net/ktnx/mobileledger/dao/TemplateHeaderDAO.java
new file mode 100644 (file)
index 0000000..67dcef8
--- /dev/null
@@ -0,0 +1,158 @@
+/*
+ * Copyright © 2022 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.dao;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.Observer;
+import androidx.room.Dao;
+import androidx.room.Delete;
+import androidx.room.Insert;
+import androidx.room.Query;
+import androidx.room.Transaction;
+import androidx.room.Update;
+
+import net.ktnx.mobileledger.db.DB;
+import net.ktnx.mobileledger.db.TemplateAccount;
+import net.ktnx.mobileledger.db.TemplateHeader;
+import net.ktnx.mobileledger.db.TemplateWithAccounts;
+import net.ktnx.mobileledger.utils.Misc;
+
+import java.util.List;
+
+@Dao
+public abstract class TemplateHeaderDAO {
+    @Insert()
+    public abstract long insertSync(TemplateHeader item);
+
+    public void insertAsync(@NonNull TemplateHeader item, @Nullable Runnable callback) {
+        BaseDAO.runAsync(() -> {
+            insertSync(item);
+            if (callback != null)
+                Misc.onMainThread(callback);
+        });
+    }
+
+    @Update
+    public abstract void updateSync(TemplateHeader... items);
+
+    @Delete
+    public abstract void deleteSync(TemplateHeader item);
+
+    public void deleteAsync(@NonNull TemplateHeader item, @NonNull Runnable callback) {
+        BaseDAO.runAsync(() -> {
+            deleteSync(item);
+            Misc.onMainThread(callback);
+        });
+    }
+
+    @Query("DELETE FROM templates")
+    public abstract void deleteAllSync();
+
+    @Query("SELECT * FROM templates ORDER BY is_fallback, UPPER(name)")
+    public abstract LiveData<List<TemplateHeader>> getTemplates();
+
+    @Query("SELECT * FROM templates WHERE id = :id")
+    public abstract LiveData<TemplateHeader> getTemplate(Long id);
+
+    @Query("SELECT * FROM templates WHERE id = :id")
+    public abstract TemplateHeader getTemplateSync(Long id);
+
+    public void getTemplateAsync(@NonNull Long id,
+                                 @NonNull AsyncResultCallback<TemplateHeader> callback) {
+        LiveData<TemplateHeader> resultReceiver = getTemplate(id);
+        resultReceiver.observeForever(new Observer<TemplateHeader>() {
+            @Override
+            public void onChanged(TemplateHeader h) {
+                if (h == null)
+                    return;
+
+                resultReceiver.removeObserver(this);
+                callback.onResult(h);
+            }
+        });
+    }
+
+    @Transaction
+    @Query("SELECT * FROM templates WHERE id = :id")
+    public abstract LiveData<TemplateWithAccounts> getTemplateWithAccounts(@NonNull Long id);
+
+    @Transaction
+    @Query("SELECT * FROM templates WHERE id = :id")
+    public abstract TemplateWithAccounts getTemplateWithAccountsSync(@NonNull Long id);
+
+    @Transaction
+    @Query("SELECT * FROM templates WHERE uuid = :uuid")
+    public abstract TemplateWithAccounts getTemplateWithAccountsByUuidSync(String uuid);
+
+    @Transaction
+    @Query("SELECT * FROM templates")
+    public abstract List<TemplateWithAccounts> getAllTemplatesWithAccountsSync();
+
+    @Transaction
+    public void insertSync(TemplateWithAccounts templateWithAccounts) {
+        long template_id = insertSync(templateWithAccounts.header);
+        for (TemplateAccount acc : templateWithAccounts.accounts) {
+            acc.setTemplateId(template_id);
+            DB.get()
+              .getTemplateAccountDAO()
+              .insertSync(acc);
+        }
+    }
+
+    public void getTemplateWithAccountsAsync(@NonNull Long id, @NonNull
+            AsyncResultCallback<TemplateWithAccounts> callback) {
+        LiveData<TemplateWithAccounts> resultReceiver = getTemplateWithAccounts(id);
+        resultReceiver.observeForever(new Observer<TemplateWithAccounts>() {
+            @Override
+            public void onChanged(TemplateWithAccounts result) {
+                if (result == null)
+                    return;
+
+                resultReceiver.removeObserver(this);
+                callback.onResult(result);
+            }
+        });
+    }
+    public void insertAsync(@NonNull TemplateWithAccounts item, @Nullable Runnable callback) {
+        BaseDAO.runAsync(() -> {
+            insertSync(item);
+            if (callback != null)
+                Misc.onMainThread(callback);
+        });
+    }
+    public void duplicateTemplateWithAccounts(@NonNull Long id, @Nullable
+            AsyncResultCallback<TemplateWithAccounts> callback) {
+        BaseDAO.runAsync(() -> {
+            TemplateWithAccounts src = getTemplateWithAccountsSync(id);
+            TemplateWithAccounts dup = src.createDuplicate();
+            dup.header.setName(dup.header.getName());
+            dup.header.setId(insertSync(dup.header));
+            TemplateAccountDAO accDao = DB.get()
+                                          .getTemplateAccountDAO();
+            for (TemplateAccount dupAcc : dup.accounts) {
+                dupAcc.setTemplateId(dup.header.getId());
+                dupAcc.setId(accDao.insertSync(dupAcc));
+            }
+            if (callback != null)
+                Misc.onMainThread(() -> callback.onResult(dup));
+        });
+    }
+
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/dao/TransactionAccountDAO.java b/app/src/main/java/net/ktnx/mobileledger/dao/TransactionAccountDAO.java
new file mode 100644 (file)
index 0000000..1b6f27d
--- /dev/null
@@ -0,0 +1,55 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.dao;
+
+import androidx.lifecycle.LiveData;
+import androidx.room.Dao;
+import androidx.room.Delete;
+import androidx.room.Insert;
+import androidx.room.OnConflictStrategy;
+import androidx.room.Query;
+import androidx.room.Update;
+
+import net.ktnx.mobileledger.db.TransactionAccount;
+
+import java.util.List;
+
+@Dao
+public abstract class TransactionAccountDAO extends BaseDAO<TransactionAccount> {
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    public abstract long insertSync(TransactionAccount item);
+
+    @Update
+    public abstract void updateSync(TransactionAccount item);
+
+    @Delete
+    public abstract void deleteSync(TransactionAccount item);
+
+    @Delete
+    public abstract void deleteSync(List<TransactionAccount> items);
+
+    @Query("DELETE FROM transaction_accounts")
+    public abstract void deleteAllSync();
+
+    @Query("SELECT * FROM transaction_accounts WHERE id = :id")
+    public abstract LiveData<TransactionAccount> getById(long id);
+
+    @Query("SELECT * FROM transaction_accounts WHERE transaction_id = :transactionId AND order_no" +
+           " = :orderNo")
+    public abstract TransactionAccount getByOrderNoSync(long transactionId, int orderNo);
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/dao/TransactionDAO.java b/app/src/main/java/net/ktnx/mobileledger/dao/TransactionDAO.java
new file mode 100644 (file)
index 0000000..c398dbd
--- /dev/null
@@ -0,0 +1,298 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.dao;
+
+import androidx.annotation.NonNull;
+import androidx.lifecycle.LiveData;
+import androidx.room.ColumnInfo;
+import androidx.room.Dao;
+import androidx.room.Delete;
+import androidx.room.Insert;
+import androidx.room.OnConflictStrategy;
+import androidx.room.Query;
+import androidx.room.Update;
+
+import net.ktnx.mobileledger.db.Account;
+import net.ktnx.mobileledger.db.AccountValue;
+import net.ktnx.mobileledger.db.DB;
+import net.ktnx.mobileledger.db.Transaction;
+import net.ktnx.mobileledger.db.TransactionAccount;
+import net.ktnx.mobileledger.db.TransactionWithAccounts;
+import net.ktnx.mobileledger.model.LedgerAccount;
+import net.ktnx.mobileledger.utils.Logger;
+import net.ktnx.mobileledger.utils.Misc;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+@Dao
+public abstract class TransactionDAO extends BaseDAO<Transaction> {
+    static public List<String> unbox(List<DescriptionContainer> list) {
+        ArrayList<String> result = new ArrayList<>(list.size());
+        for (DescriptionContainer item : list) {
+            result.add(item.description);
+        }
+
+        return result;
+    }
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    public abstract long insertSync(Transaction item);
+
+    @Update
+    public abstract void updateSync(Transaction item);
+
+    @Delete
+    public abstract void deleteSync(Transaction item);
+
+    @Delete
+    public abstract void deleteSync(Transaction... items);
+
+    @Delete
+    public abstract void deleteSync(List<Transaction> items);
+
+    @Query("DELETE FROM transactions")
+    public abstract void deleteAllSync();
+
+    @Query("SELECT * FROM transactions WHERE id = :id")
+    public abstract LiveData<Transaction> getById(long id);
+
+    @androidx.room.Transaction
+    @Query("SELECT * FROM transactions WHERE id = :transactionId")
+    public abstract LiveData<TransactionWithAccounts> getByIdWithAccounts(long transactionId);
+
+    @androidx.room.Transaction
+    @Query("SELECT * FROM transactions WHERE id = :transactionId")
+    public abstract TransactionWithAccounts getByIdWithAccountsSync(long transactionId);
+
+    @Query("SELECT DISTINCT description, CASE WHEN description_uc LIKE :term||'%' THEN 1 " +
+           "               WHEN description_uc LIKE '%:'||:term||'%' THEN 2 " +
+           "               WHEN description_uc LIKE '% '||:term||'%' THEN 3 " +
+           "               ELSE 9 END AS ordering FROM transactions " +
+           "WHERE description_uc LIKE '%'||:term||'%' ORDER BY ordering, description_uc, rowid ")
+    public abstract List<DescriptionContainer> lookupDescriptionSync(@NonNull String term);
+
+    @androidx.room.Transaction
+    @Query("SELECT * from transactions WHERE description = :description ORDER BY year desc, month" +
+           " desc, day desc LIMIT 1")
+    public abstract TransactionWithAccounts getFirstByDescriptionSync(@NonNull String description);
+
+    @androidx.room.Transaction
+    @Query("SELECT tr.id, tr.profile_id, tr.ledger_id, tr.description, tr.description_uc, tr" +
+           ".data_hash, tr.comment, tr.year, tr.month, tr.day, tr.generation from transactions tr" +
+           " JOIN transaction_accounts t_a ON t_a.transaction_id = tr.id WHERE tr.description = " +
+           ":description AND t_a.account_name LIKE '%'||:accountTerm||'%' ORDER BY year desc, " +
+           "month desc, day desc, tr.ledger_id desc LIMIT 1")
+    public abstract TransactionWithAccounts getFirstByDescriptionHavingAccountSync(
+            @NonNull String description, @NonNull String accountTerm);
+
+    @Query("SELECT * from transactions WHERE profile_id = :profileId")
+    public abstract List<Transaction> getAllForProfileUnorderedSync(long profileId);
+
+    @Query("SELECT generation FROM transactions WHERE profile_id = :profileId LIMIT 1")
+    protected abstract TransactionGenerationContainer getGenerationPOJOSync(long profileId);
+
+    @androidx.room.Transaction
+    @Query("SELECT * FROM transactions WHERE profile_id = :profileId ORDER BY year " +
+           " asc, month asc, day asc, ledger_id asc")
+    public abstract LiveData<List<TransactionWithAccounts>> getAllWithAccounts(long profileId);
+
+    @androidx.room.Transaction
+    @Query("SELECT distinct(tr.id), tr.ledger_id, tr.profile_id, tr.data_hash, tr.year, tr.month," +
+           " tr.day, tr.description, tr.description_uc, tr.comment, tr.generation FROM " +
+           "transactions tr JOIN transaction_accounts ta ON ta.transaction_id=tr.id WHERE ta" +
+           ".account_name LIKE :accountName||'%' AND ta.amount <> 0 AND tr.profile_id = " +
+           ":profileId ORDER BY tr.year asc, tr.month asc, tr.day asc, tr.ledger_id asc")
+    public abstract LiveData<List<TransactionWithAccounts>> getAllWithAccountsFiltered(
+            long profileId, String accountName);
+
+    @Query("DELETE FROM transactions WHERE profile_id = :profileId AND generation <> " +
+           ":currentGeneration")
+    public abstract int purgeOldTransactionsSync(long profileId, long currentGeneration);
+
+    @Query("DELETE FROM transaction_accounts WHERE EXISTS (SELECT 1 FROM transactions tr WHERE tr" +
+           ".id=transaction_accounts.transaction_id AND tr.profile_id=:profileId) AND generation " +
+           "<> :currentGeneration")
+    public abstract int purgeOldTransactionAccountsSync(long profileId, long currentGeneration);
+
+    @Query("DELETE FROM transactions WHERE profile_id = :profileId")
+    public abstract int deleteAllSync(long profileId);
+
+    @Query("SELECT * FROM transactions where profile_id = :profileId AND ledger_id = :ledgerId")
+    public abstract Transaction getByLedgerId(long profileId, long ledgerId);
+
+    @Query("UPDATE transactions SET generation = :newGeneration WHERE id = :transactionId")
+    public abstract int updateGeneration(long transactionId, long newGeneration);
+
+    @Query("UPDATE transaction_accounts SET generation = :newGeneration WHERE transaction_id = " +
+           ":transactionId")
+    public abstract int updateAccountsGeneration(long transactionId, long newGeneration);
+
+    @Query("SELECT max(ledger_id) as ledger_id FROM transactions WHERE profile_id = :profileId")
+    public abstract LedgerIdContainer getMaxLedgerIdPOJOSync(long profileId);
+    @androidx.room.Transaction
+    public void updateGenerationWithAccounts(long transactionId, long newGeneration) {
+        updateGeneration(transactionId, newGeneration);
+        updateAccountsGeneration(transactionId, newGeneration);
+    }
+    public long getGenerationSync(long profileId) {
+        TransactionGenerationContainer result = getGenerationPOJOSync(profileId);
+
+        if (result == null)
+            return 0;
+        return result.generation;
+    }
+    public long getMaxLedgerIdSync(long profileId) {
+        LedgerIdContainer result = getMaxLedgerIdPOJOSync(profileId);
+
+        if (result == null)
+            return 0;
+        return result.ledgerId;
+    }
+    @androidx.room.Transaction
+    public void storeTransactionsSync(List<TransactionWithAccounts> list, long profileId) {
+        long generation = getGenerationSync(profileId) + 1;
+
+        for (TransactionWithAccounts tr : list) {
+            tr.transaction.setGeneration(generation);
+            tr.transaction.setProfileId(profileId);
+
+            storeSync(tr);
+        }
+
+        Logger.debug("Transaction", "Purging old transactions");
+        int removed = purgeOldTransactionsSync(profileId, generation);
+        Logger.debug("Transaction", String.format(Locale.ROOT, "Purged %d transactions", removed));
+
+        removed = purgeOldTransactionAccountsSync(profileId, generation);
+        Logger.debug("Transaction",
+                String.format(Locale.ROOT, "Purged %d transaction accounts", removed));
+    }
+    @androidx.room.Transaction
+    void storeSync(TransactionWithAccounts rec) {
+        TransactionAccountDAO trAccDao = DB.get()
+                                           .getTransactionAccountDAO();
+
+        Transaction transaction = rec.transaction;
+        Transaction existing = getByLedgerId(transaction.getProfileId(), transaction.getLedgerId());
+        if (existing != null) {
+            if (Misc.equalStrings(transaction.getDataHash(), existing.getDataHash())) {
+                updateGenerationWithAccounts(existing.getId(), rec.transaction.getGeneration());
+                return;
+            }
+
+            existing.copyDataFrom(transaction);
+            updateSync(existing);
+
+            transaction = existing;
+        }
+        else
+            transaction.setId(insertSync(transaction));
+
+        for (TransactionAccount trAcc : rec.accounts) {
+            trAcc.setTransactionId(transaction.getId());
+            trAcc.setGeneration(transaction.getGeneration());
+            TransactionAccount existingAcc =
+                    trAccDao.getByOrderNoSync(trAcc.getTransactionId(), trAcc.getOrderNo());
+            if (existingAcc != null) {
+                existingAcc.copyDataFrom(trAcc);
+                trAccDao.updateSync(existingAcc);
+            }
+            else
+                trAcc.setId(trAccDao.insertSync(trAcc));
+        }
+    }
+    public void storeLast(TransactionWithAccounts rec) {
+        BaseDAO.runAsync(() -> appendSync(rec));
+    }
+    @androidx.room.Transaction
+    public void appendSync(TransactionWithAccounts rec) {
+        TransactionAccountDAO trAccDao = DB.get()
+                                           .getTransactionAccountDAO();
+        AccountDAO accDao = DB.get()
+                              .getAccountDAO();
+        AccountValueDAO accValDao = DB.get()
+                                      .getAccountValueDAO();
+
+        Transaction transaction = rec.transaction;
+        final long profileId = transaction.getProfileId();
+        transaction.setGeneration(getGenerationSync(profileId));
+        transaction.setLedgerId(getMaxLedgerIdSync(profileId) + 1);
+        transaction.setId(insertSync(transaction));
+
+        for (TransactionAccount trAcc : rec.accounts) {
+            trAcc.setTransactionId(transaction.getId());
+            trAcc.setGeneration(transaction.getGeneration());
+            trAcc.setId(trAccDao.insertSync(trAcc));
+
+            String accName = trAcc.getAccountName();
+            while (accName != null) {
+                Account acc = accDao.getByNameSync(profileId, accName);
+                if (acc == null) {
+                    acc = new Account();
+                    acc.setProfileId(profileId);
+                    acc.setName(accName);
+                    acc.setNameUpper(accName.toUpperCase());
+                    acc.setParentName(LedgerAccount.extractParentName(accName));
+                    acc.setLevel(LedgerAccount.determineLevel(acc.getName()));
+                    acc.setGeneration(trAcc.getGeneration());
+
+                    acc.setId(accDao.insertSync(acc));
+                }
+
+                AccountValue accVal = accValDao.getByCurrencySync(acc.getId(), trAcc.getCurrency());
+                if (accVal == null) {
+                    accVal = new AccountValue();
+                    accVal.setAccountId(acc.getId());
+                    accVal.setGeneration(trAcc.getGeneration());
+                    accVal.setCurrency(trAcc.getCurrency());
+                    accVal.setValue(trAcc.getAmount());
+                    accVal.setId(accValDao.insertSync(accVal));
+                }
+                else {
+                    accVal.setValue(accVal.getValue() + trAcc.getAmount());
+                    accValDao.updateSync(accVal);
+                }
+
+                accName = LedgerAccount.extractParentName(accName);
+            }
+        }
+    }
+    static class TransactionGenerationContainer {
+        @ColumnInfo
+        long generation;
+        public TransactionGenerationContainer(long generation) {
+            this.generation = generation;
+        }
+    }
+
+    static class LedgerIdContainer {
+        @ColumnInfo(name = "ledger_id")
+        long ledgerId;
+        public LedgerIdContainer(long ledgerId) {
+            this.ledgerId = ledgerId;
+        }
+    }
+
+    static public class DescriptionContainer {
+        @ColumnInfo
+        public String description;
+        @ColumnInfo
+        public int ordering;
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/db/Account.java b/app/src/main/java/net/ktnx/mobileledger/db/Account.java
new file mode 100644 (file)
index 0000000..82524fc
--- /dev/null
@@ -0,0 +1,117 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.db;
+
+import androidx.annotation.NonNull;
+import androidx.room.ColumnInfo;
+import androidx.room.Entity;
+import androidx.room.ForeignKey;
+import androidx.room.Index;
+import androidx.room.PrimaryKey;
+
+@Entity(tableName = "accounts",
+        indices = {@Index(name = "un_account_name", unique = true, value = {"profile_id", "name"}),
+                   @Index(name = "fk_account_profile", value = "profile_id")
+        }, foreignKeys = {
+        @ForeignKey(entity = Profile.class, parentColumns = "id", childColumns = "profile_id",
+                    onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.RESTRICT)
+})
+public class Account {
+    @ColumnInfo
+    @PrimaryKey(autoGenerate = true)
+    long id;
+    @ColumnInfo(name = "profile_id")
+    long profileId;
+    @ColumnInfo
+    int level;
+    @ColumnInfo
+    @NonNull
+    private String name;
+    @NonNull
+    @ColumnInfo(name = "name_upper")
+    private String nameUpper;
+    @ColumnInfo(name = "parent_name")
+    private String parentName;
+    @ColumnInfo(defaultValue = "1")
+    private boolean expanded = true;
+    @ColumnInfo(name = "amounts_expanded", defaultValue = "0")
+    private boolean amountsExpanded = false;
+    @ColumnInfo(defaultValue = "0")
+    private long generation;
+    public long getId() {
+        return id;
+    }
+    public void setId(long id) {
+        this.id = id;
+    }
+    public long getProfileId() {
+        return profileId;
+    }
+    public void setProfileId(long profileId) {
+        this.profileId = profileId;
+    }
+    @NonNull
+    public String getName() {
+        return name;
+    }
+    public void setName(@NonNull String name) {
+        this.name = name;
+    }
+    @NonNull
+    public String getNameUpper() {
+        return nameUpper;
+    }
+    public void setNameUpper(@NonNull String nameUpper) {
+        this.nameUpper = nameUpper;
+    }
+    public int getLevel() {
+        return level;
+    }
+    public void setLevel(int level) {
+        this.level = level;
+    }
+    public String getParentName() {
+        return parentName;
+    }
+    public void setParentName(String parentName) {
+        this.parentName = parentName;
+    }
+    public boolean isExpanded() {
+        return expanded;
+    }
+    public void setExpanded(boolean expanded) {
+        this.expanded = expanded;
+    }
+    public boolean isAmountsExpanded() {
+        return amountsExpanded;
+    }
+    public void setAmountsExpanded(boolean amountsExpanded) {
+        this.amountsExpanded = amountsExpanded;
+    }
+    public long getGeneration() {
+        return generation;
+    }
+    public void setGeneration(long generation) {
+        this.generation = generation;
+    }
+    @NonNull
+    @Override
+    public String toString() {
+        return getName();
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/db/AccountAutocompleteAdapter.java b/app/src/main/java/net/ktnx/mobileledger/db/AccountAutocompleteAdapter.java
new file mode 100644 (file)
index 0000000..6a92b61
--- /dev/null
@@ -0,0 +1,102 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.db;
+
+import android.content.Context;
+import android.widget.ArrayAdapter;
+import android.widget.Filter;
+
+import androidx.annotation.NonNull;
+
+import net.ktnx.mobileledger.dao.AccountDAO;
+import net.ktnx.mobileledger.utils.Logger;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static net.ktnx.mobileledger.db.Profile.NO_PROFILE_ID;
+
+public class AccountAutocompleteAdapter extends ArrayAdapter<String> {
+    private final AccountFilter filter = new AccountFilter();
+    private final AccountDAO dao = DB.get()
+                                     .getAccountDAO();
+    private long profileId = NO_PROFILE_ID;
+    public AccountAutocompleteAdapter(Context context) {
+        super(context, android.R.layout.simple_dropdown_item_1line, new ArrayList<>());
+    }
+    public AccountAutocompleteAdapter(Context context, @NonNull Profile profile) {
+        this(context);
+        profileId = profile.getId();
+    }
+    public void setProfileId(long profileId) {
+        this.profileId = profileId;
+    }
+    @NonNull
+    @Override
+    public Filter getFilter() {
+        return filter;
+    }
+    //    @NonNull
+//    @Override
+//    public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
+//        View view = convertView;
+//        if (view == null) {
+//            view = LayoutInflater.from(parent.getContext())
+//                                 .inflate(android.R.layout.simple_dropdown_item_1line, parent,
+//                                         false);
+//        }
+//        Account item = getItem(position);
+//        ((TextView) view.findViewById(android.R.id.text1)).setText(item.getName());
+//        return view;
+//    }
+    class AccountFilter extends Filter {
+        @Override
+        protected FilterResults performFiltering(CharSequence constraint) {
+            FilterResults results = new FilterResults();
+            if (constraint == null) {
+                results.count = 0;
+                return results;
+            }
+
+            Logger.debug("acc", String.format("Looking for account '%s'", constraint));
+            final List<String> matches = AccountDAO.unbox(
+                    (profileId == NO_PROFILE_ID) ? dao.lookupNamesByNameSync(
+                            String.valueOf(constraint)
+                                  .toUpperCase()) : dao.lookupNamesInProfileByNameSync(profileId,
+                            String.valueOf(constraint)
+                                  .toUpperCase()));
+            results.values = matches;
+            results.count = matches.size();
+
+            return results;
+        }
+        @Override
+        @SuppressWarnings("unchecked")
+        protected void publishResults(CharSequence constraint, FilterResults results) {
+            if (results.values == null) {
+                notifyDataSetInvalidated();
+            }
+            else {
+                setNotifyOnChange(false);
+                clear();
+                addAll((List<String>) results.values);
+                notifyDataSetChanged();
+            }
+        }
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/db/AccountValue.java b/app/src/main/java/net/ktnx/mobileledger/db/AccountValue.java
new file mode 100644 (file)
index 0000000..2ebc3ec
--- /dev/null
@@ -0,0 +1,79 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.db;
+
+import androidx.annotation.NonNull;
+import androidx.room.ColumnInfo;
+import androidx.room.Entity;
+import androidx.room.ForeignKey;
+import androidx.room.Index;
+import androidx.room.PrimaryKey;
+
+
+@Entity(tableName = "account_values", indices = {
+        @Index(name = "un_account_values", unique = true, value = {"account_id", "currency"}),
+        @Index(name = "fk_account_value_acc", value = "account_id")
+}, foreignKeys = {
+        @ForeignKey(entity = Account.class, parentColumns = "id", childColumns = "account_id",
+                    onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.RESTRICT)
+})
+public class AccountValue {
+    @ColumnInfo
+    @PrimaryKey(autoGenerate = true)
+    long id;
+    @ColumnInfo(name = "account_id")
+    private long accountId;
+    @NonNull
+    @ColumnInfo(defaultValue = "")
+    private String currency = "";
+    @ColumnInfo
+    private float value;
+    @ColumnInfo(defaultValue = "0")
+    private long generation = 0;
+    public long getId() {
+        return id;
+    }
+    public void setId(long id) {
+        this.id = id;
+    }
+    public long getAccountId() {
+        return accountId;
+    }
+    public void setAccountId(long accountId) {
+        this.accountId = accountId;
+    }
+    @NonNull
+    public String getCurrency() {
+        return currency;
+    }
+    public void setCurrency(@NonNull String currency) {
+        this.currency = currency;
+    }
+    public float getValue() {
+        return value;
+    }
+    public void setValue(float value) {
+        this.value = value;
+    }
+    public long getGeneration() {
+        return generation;
+    }
+    public void setGeneration(long generation) {
+        this.generation = generation;
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/db/AccountWithAmounts.java b/app/src/main/java/net/ktnx/mobileledger/db/AccountWithAmounts.java
new file mode 100644 (file)
index 0000000..86742ca
--- /dev/null
@@ -0,0 +1,36 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.db;
+
+import androidx.annotation.NonNull;
+import androidx.room.Embedded;
+import androidx.room.Relation;
+
+import java.util.List;
+
+public class AccountWithAmounts {
+    @Embedded
+    public Account account;
+    @Relation(parentColumn = "id", entityColumn = "account_id")
+    public List<AccountValue> amounts;
+    @NonNull
+    @Override
+    public String toString() {
+        return account.getName();
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/db/AccountWithAmountsAutocompleteAdapter.java b/app/src/main/java/net/ktnx/mobileledger/db/AccountWithAmountsAutocompleteAdapter.java
new file mode 100644 (file)
index 0000000..e99df9f
--- /dev/null
@@ -0,0 +1,109 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.db;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.Filter;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import net.ktnx.mobileledger.R;
+import net.ktnx.mobileledger.dao.AccountDAO;
+import net.ktnx.mobileledger.model.Data;
+import net.ktnx.mobileledger.utils.Logger;
+import net.ktnx.mobileledger.utils.Misc;
+
+import java.util.List;
+
+public class AccountWithAmountsAutocompleteAdapter extends ArrayAdapter<AccountWithAmounts> {
+    private final AccountFilter filter = new AccountFilter();
+    private final long profileId;
+    public AccountWithAmountsAutocompleteAdapter(Context context, @NonNull Profile profile) {
+        super(context, R.layout.account_autocomplete_row);
+        profileId = profile.getId();
+    }
+    @NonNull
+    @Override
+    public Filter getFilter() {
+        return filter;
+    }
+    @NonNull
+    @Override
+    public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
+        View view = convertView;
+        if (view == null) {
+            view = LayoutInflater.from(parent.getContext())
+                                 .inflate(R.layout.account_autocomplete_row, parent, false);
+        }
+        AccountWithAmounts item = getItem(position);
+        ((TextView) view.findViewById(R.id.account_name)).setText(item.account.getName());
+        StringBuilder amountsText = new StringBuilder();
+        for (AccountValue amt : item.amounts) {
+            if (amountsText.length() != 0)
+                amountsText.append('\n');
+            String currency = amt.getCurrency();
+            if (Misc.emptyIsNull(currency) != null)
+                amountsText.append(currency)
+                           .append(' ');
+            amountsText.append(Data.formatNumber(amt.getValue()));
+        }
+        ((TextView) view.findViewById(R.id.amounts)).setText(amountsText.toString());
+
+        return view;
+    }
+    class AccountFilter extends Filter {
+        private final AccountDAO dao = DB.get()
+                                         .getAccountDAO();
+        @Override
+        protected FilterResults performFiltering(CharSequence constraint) {
+            FilterResults results = new FilterResults();
+            if (constraint == null) {
+                results.count = 0;
+                return results;
+            }
+
+            Logger.debug("acc", String.format("Looking for account '%s'", constraint));
+            final List<AccountWithAmounts> matches =
+                    dao.lookupWithAmountsInProfileByNameSync(profileId, String.valueOf(constraint)
+                                                                              .toUpperCase());
+            results.values = matches;
+            results.count = matches.size();
+
+            return results;
+        }
+        @Override
+        @SuppressWarnings("unchecked")
+        protected void publishResults(CharSequence constraint, FilterResults results) {
+            if (results.values == null) {
+                notifyDataSetInvalidated();
+            }
+            else {
+                setNotifyOnChange(false);
+                clear();
+                addAll((List<AccountWithAmounts>) results.values);
+                notifyDataSetChanged();
+            }
+        }
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/db/Currency.java b/app/src/main/java/net/ktnx/mobileledger/db/Currency.java
new file mode 100644 (file)
index 0000000..8552443
--- /dev/null
@@ -0,0 +1,80 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.db;
+
+import androidx.annotation.NonNull;
+import androidx.room.ColumnInfo;
+import androidx.room.Entity;
+import androidx.room.Ignore;
+import androidx.room.Index;
+import androidx.room.PrimaryKey;
+
+@Entity(tableName = "currencies",
+        indices = {@Index(name = "currency_name_idx", unique = true, value = "name")})
+public class Currency {
+    @PrimaryKey(autoGenerate = true)
+    private long id;
+    @NonNull
+    private String name;
+    @NonNull
+    private String position;
+    @NonNull
+    @ColumnInfo(name = "has_gap")
+    private Boolean hasGap;
+    @Ignore
+    public Currency() {
+        id = 0;
+        name = "";
+        position = "after";
+        hasGap = true;
+    }
+    public Currency(long id, @NonNull String name, @NonNull String position,
+                    @NonNull Boolean hasGap) {
+        this.id = id;
+        this.name = name;
+        this.position = position;
+        this.hasGap = hasGap;
+    }
+    public long getId() {
+        return id;
+    }
+    public void setId(long id) {
+        this.id = id;
+    }
+    @NonNull
+    public String getName() {
+        return name;
+    }
+    public void setName(@NonNull String name) {
+        this.name = name;
+    }
+    @NonNull
+    public String getPosition() {
+        return position;
+    }
+    public void setPosition(@NonNull String position) {
+        this.position = position;
+    }
+    @NonNull
+    public Boolean getHasGap() {
+        return hasGap;
+    }
+    public void setHasGap(@NonNull Boolean hasGap) {
+        this.hasGap = hasGap;
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/db/DB.java b/app/src/main/java/net/ktnx/mobileledger/db/DB.java
new file mode 100644 (file)
index 0000000..7d3e780
--- /dev/null
@@ -0,0 +1,271 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.db;
+
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.database.SQLException;
+
+import androidx.annotation.NonNull;
+import androidx.lifecycle.MutableLiveData;
+import androidx.room.Database;
+import androidx.room.Room;
+import androidx.room.RoomDatabase;
+import androidx.room.migration.Migration;
+import androidx.sqlite.db.SupportSQLiteDatabase;
+
+import net.ktnx.mobileledger.App;
+import net.ktnx.mobileledger.dao.AccountDAO;
+import net.ktnx.mobileledger.dao.AccountValueDAO;
+import net.ktnx.mobileledger.dao.CurrencyDAO;
+import net.ktnx.mobileledger.dao.OptionDAO;
+import net.ktnx.mobileledger.dao.ProfileDAO;
+import net.ktnx.mobileledger.dao.TemplateAccountDAO;
+import net.ktnx.mobileledger.dao.TemplateHeaderDAO;
+import net.ktnx.mobileledger.dao.TransactionAccountDAO;
+import net.ktnx.mobileledger.dao.TransactionDAO;
+import net.ktnx.mobileledger.utils.Logger;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.Locale;
+import java.util.UUID;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static net.ktnx.mobileledger.utils.Logger.debug;
+
+@Database(version = DB.REVISION,
+          entities = {TemplateHeader.class, TemplateAccount.class, Currency.class, Account.class,
+                      Profile.class, Option.class, AccountValue.class, Transaction.class,
+                      TransactionAccount.class
+          })
+abstract public class DB extends RoomDatabase {
+    public static final int REVISION = 66;
+    public static final String DB_NAME = "MoLe.db";
+    public static final MutableLiveData<Boolean> initComplete = new MutableLiveData<>(false);
+    private static DB instance;
+    private static void fixTransactionDescriptionUpper(
+            @NonNull @NotNull SupportSQLiteDatabase database) {
+        try (Cursor c = database.query("SELECT id, description FROM transactions")) {
+            while (c.moveToNext()) {
+                final long id = c.getLong(0);
+                final String description = c.getString(1);
+                database.execSQL("UPDATE transactions SET description_uc=? WHERE id=?",
+                        new Object[]{description.toUpperCase(), id
+                        });
+            }
+        }
+    }
+    public static DB get() {
+        if (instance != null)
+            return instance;
+        synchronized (DB.class) {
+            if (instance != null)
+                return instance;
+
+            RoomDatabase.Builder<DB> builder =
+                    Room.databaseBuilder(App.instance, DB.class, DB_NAME);
+            builder.addMigrations(
+                    new Migration[]{singleVersionMigration(17), singleVersionMigration(18),
+                                    singleVersionMigration(19), singleVersionMigration(20),
+                                    multiVersionMigration(20, 22), multiVersionMigration(22, 30),
+                                    multiVersionMigration(30, 32), multiVersionMigration(32, 34),
+                                    multiVersionMigration(34, 40), singleVersionMigration(41),
+                                    multiVersionMigration(41, 58), singleVersionMigration(59),
+                                    singleVersionMigration(60), singleVersionMigration(61),
+                                    singleVersionMigration(62), singleVersionMigration(63),
+                                    singleVersionMigration(64), new Migration(64, 65) {
+                        @Override
+                        public void migrate(@NonNull @NotNull SupportSQLiteDatabase database) {
+                            fixTransactionDescriptionUpper(database);
+                        }
+                    }, new Migration(64, 66) {
+                        @Override
+                        public void migrate(@NonNull @NotNull SupportSQLiteDatabase database) {
+                            fixTransactionDescriptionUpper(database);
+                        }
+                    }, new Migration(65, 66) {
+                        @Override
+                        public void migrate(@NonNull @NotNull SupportSQLiteDatabase database) {
+                            fixTransactionDescriptionUpper(database);
+                        }
+                    }
+                    })
+                   .addCallback(new Callback() {
+                       @Override
+                       public void onOpen(@NonNull SupportSQLiteDatabase db) {
+                           super.onOpen(db);
+                           db.execSQL("PRAGMA foreign_keys = ON");
+                           db.execSQL("pragma case_sensitive_like" + "=ON;");
+
+                       }
+                   });
+
+//            if (BuildConfig.DEBUG)
+//                builder.setQueryCallback(((sqlQuery, bindArgs) -> Logger.debug("room", sqlQuery)),
+//                        Executors.newSingleThreadExecutor());
+
+            return instance = builder.build();
+        }
+    }
+    private static Migration singleVersionMigration(int toVersion) {
+        return new Migration(toVersion - 1, toVersion) {
+            @Override
+            public void migrate(@NonNull SupportSQLiteDatabase db) {
+                String fileName = String.format(Locale.US, "db_%d", toVersion);
+
+                applyRevisionFile(db, fileName);
+
+                // when migrating to version 59, migrate profile/theme options to the
+                // SharedPreferences
+                if (toVersion == 59) {
+                    try (Cursor c = db.query(
+                            "SELECT p.id, p.theme FROM profiles p WHERE p.id=(SELECT o.value " +
+                            "FROM options o WHERE o.profile_id=0 AND o.name=?)",
+                            new Object[]{"profile_id"}))
+                    {
+                        if (c.moveToFirst()) {
+                            long currentProfileId = c.getLong(0);
+                            int currentTheme = c.getInt(1);
+
+                            if (currentProfileId >= 0 && currentTheme >= 0) {
+                                App.storeStartupProfileAndTheme(currentProfileId, currentTheme);
+                            }
+                        }
+                    }
+                }
+                if (toVersion == 63) {
+                    try (Cursor c = db.query("SELECT id FROM templates")) {
+                        while (c.moveToNext()) {
+                            db.execSQL("UPDATE templates SET uuid=? WHERE id=?",
+                                    new Object[]{UUID.randomUUID().toString(), c.getLong(0)});
+                        }
+                    }
+                }
+            }
+        };
+    }
+    private static Migration dummyVersionMigration(int toVersion) {
+        return new Migration(toVersion - 1, toVersion) {
+            @Override
+            public void migrate(@NonNull SupportSQLiteDatabase db) {
+                Logger.debug("db",
+                        String.format(Locale.ROOT, "Dummy DB migration to version %d", toVersion));
+            }
+        };
+    }
+    private static Migration multiVersionMigration(int fromVersion, int toVersion) {
+        return new Migration(fromVersion, toVersion) {
+            @Override
+            public void migrate(@NonNull SupportSQLiteDatabase db) {
+                String fileName = String.format(Locale.US, "db_%d_%d", fromVersion, toVersion);
+
+                applyRevisionFile(db, fileName);
+            }
+        };
+    }
+    public static void applyRevisionFile(@NonNull SupportSQLiteDatabase db, String fileName) {
+        final Resources rm = App.instance.getResources();
+        int res_id = rm.getIdentifier(fileName, "raw", App.instance.getPackageName());
+        if (res_id == 0)
+            throw new SQLException(String.format(Locale.US, "No resource for %s", fileName));
+
+        try (InputStream res = rm.openRawResource(res_id)) {
+            debug("db", "Applying " + fileName);
+            InputStreamReader isr = new InputStreamReader(res);
+            BufferedReader reader = new BufferedReader(isr);
+
+            Pattern endOfStatement = Pattern.compile(";\\s*(?:--.*)?$");
+
+            String line;
+            String sqlStatement = null;
+            int lineNo = 0;
+            while ((line = reader.readLine()) != null) {
+                lineNo++;
+                if (line.startsWith("--"))
+                    continue;
+                if (line.isEmpty())
+                    continue;
+
+                if (sqlStatement == null)
+                    sqlStatement = line;
+                else
+                    sqlStatement = sqlStatement.concat(" " + line);
+
+                Matcher m = endOfStatement.matcher(line);
+                if (!m.find())
+                    continue;
+
+                try {
+                    db.execSQL(sqlStatement);
+                    sqlStatement = null;
+                }
+                catch (Exception e) {
+                    throw new RuntimeException(
+                            String.format("Error applying %s, line %d, statement: %s", fileName,
+                                    lineNo, sqlStatement), e);
+                }
+            }
+
+            if (sqlStatement != null)
+                throw new RuntimeException(String.format(
+                        "Error applying %s: EOF after continuation. Line %s, Incomplete " +
+                        "statement: %s", fileName, lineNo, sqlStatement));
+
+        }
+        catch (IOException e) {
+            throw new RuntimeException(String.format("Error opening raw resource for %s", fileName),
+                    e);
+        }
+    }
+    public abstract TemplateHeaderDAO getTemplateDAO();
+
+    public abstract TemplateAccountDAO getTemplateAccountDAO();
+
+    public abstract CurrencyDAO getCurrencyDAO();
+
+    public abstract AccountDAO getAccountDAO();
+
+    public abstract AccountValueDAO getAccountValueDAO();
+
+    public abstract TransactionDAO getTransactionDAO();
+
+    public abstract TransactionAccountDAO getTransactionAccountDAO();
+
+    public abstract OptionDAO getOptionDAO();
+
+    public abstract ProfileDAO getProfileDAO();
+
+    @androidx.room.Transaction
+    public void deleteAllSync() {
+        getTransactionAccountDAO().deleteAllSync();
+        getTransactionDAO().deleteAllSync();
+        getAccountValueDAO().deleteAllSync();
+        getAccountDAO().deleteAllSync();
+        getTemplateAccountDAO().deleteAllSync();
+        getTemplateDAO().deleteAllSync();
+        getCurrencyDAO().deleteAllSync();
+        getOptionDAO().deleteAllSync();
+        getProfileDAO().deleteAllSync();
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/db/Option.java b/app/src/main/java/net/ktnx/mobileledger/db/Option.java
new file mode 100644 (file)
index 0000000..8fabb55
--- /dev/null
@@ -0,0 +1,65 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.db;
+
+import androidx.annotation.NonNull;
+import androidx.room.ColumnInfo;
+import androidx.room.Entity;
+
+import org.jetbrains.annotations.NotNull;
+
+@Entity(tableName = "options", primaryKeys = {"profile_id", "name"})
+public class Option {
+    public static final String OPT_LAST_SCRAPE = "last_scrape";
+    @ColumnInfo(name = "profile_id")
+    private long profileId;
+    @NonNull
+    @ColumnInfo
+    private String name;
+    @ColumnInfo
+    private String value;
+    public Option(long profileId, @NotNull String name, String value) {
+        this.profileId = profileId;
+        this.name = name;
+        this.value = value;
+    }
+    public long getProfileId() {
+        return profileId;
+    }
+    public void setProfileId(long profileId) {
+        this.profileId = profileId;
+    }
+    @NonNull
+    public String getName() {
+        return name;
+    }
+    public void setName(@NonNull String name) {
+        this.name = name;
+    }
+    public String getValue() {
+        return value;
+    }
+    public void setValue(String value) {
+        this.value = value;
+    }
+    @NonNull
+    @Override
+    public String toString() {
+        return getName();
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/db/Profile.java b/app/src/main/java/net/ktnx/mobileledger/db/Profile.java
new file mode 100644 (file)
index 0000000..35230e2
--- /dev/null
@@ -0,0 +1,247 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.db;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.room.ColumnInfo;
+import androidx.room.Entity;
+import androidx.room.Index;
+import androidx.room.PrimaryKey;
+import androidx.room.Transaction;
+
+import net.ktnx.mobileledger.dao.AccountDAO;
+import net.ktnx.mobileledger.dao.BaseDAO;
+import net.ktnx.mobileledger.dao.OptionDAO;
+import net.ktnx.mobileledger.dao.TransactionDAO;
+import net.ktnx.mobileledger.utils.Misc;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.UUID;
+
+@Entity(tableName = "profiles",
+        indices = {@Index(name = "profiles_uuid_idx", unique = true, value = "uuid")})
+public class Profile {
+    public static final long NO_PROFILE_ID = 0;
+    @ColumnInfo
+    @PrimaryKey(autoGenerate = true)
+    private long id;
+    @NonNull
+    @ColumnInfo
+    private String name = "";
+    @NonNull
+    @ColumnInfo()
+    private String uuid;
+    @NonNull
+    @ColumnInfo
+    private String url = "";
+    @ColumnInfo(name = "use_authentication")
+    private boolean useAuthentication;
+    @ColumnInfo(name = "auth_user")
+    private String authUser;
+    @ColumnInfo(name = "auth_password")
+    private String authPassword;
+    @ColumnInfo(name = "order_no")
+    private int orderNo;
+    @ColumnInfo(name = "permit_posting")
+    private boolean permitPosting;
+    @ColumnInfo(defaultValue = "-1")
+    private int theme = -1;
+    @ColumnInfo(name = "preferred_accounts_filter")
+    private String preferredAccountsFilter;
+    @ColumnInfo(name = "future_dates")
+    private int futureDates;
+    @ColumnInfo(name = "api_version")
+    private int apiVersion;
+    @ColumnInfo(name = "show_commodity_by_default")
+    private boolean showCommodityByDefault;
+    @ColumnInfo(name = "default_commodity")
+    private String defaultCommodity;
+    @ColumnInfo(name = "show_comments_by_default", defaultValue = "1")
+    private boolean showCommentsByDefault = true;
+    @ColumnInfo(name = "detected_version_pre_1_19")
+    private boolean detectedVersionPre_1_19;
+    @ColumnInfo(name = "detected_version_major")
+    private int detectedVersionMajor;
+    @ColumnInfo(name = "detected_version_minor")
+    private int detectedVersionMinor;
+    public Profile() {
+        uuid = UUID.randomUUID()
+                   .toString();
+    }
+    public String getUuid() {
+        return uuid;
+    }
+    public void setUuid(String uuid) {
+        this.uuid = uuid;
+    }
+    public long getId() {
+        return id;
+    }
+    public void setId(long id) {
+        this.id = id;
+    }
+    @NonNull
+    public String getName() {
+        return name;
+    }
+    public void setName(@NonNull String name) {
+        this.name = name;
+    }
+    @NonNull
+    public String getUrl() {
+        return url;
+    }
+    public void setUrl(@NonNull String url) {
+        this.url = url;
+    }
+    public boolean useAuthentication() {
+        return useAuthentication;
+    }
+    public void setUseAuthentication(boolean useAuthentication) {
+        this.useAuthentication = useAuthentication;
+    }
+    public String getAuthUser() {
+        return authUser;
+    }
+    public void setAuthUser(String authUser) {
+        this.authUser = authUser;
+    }
+    public String getAuthPassword() {
+        return authPassword;
+    }
+    public void setAuthPassword(String authPassword) {
+        this.authPassword = authPassword;
+    }
+    public int getOrderNo() {
+        return orderNo;
+    }
+    public void setOrderNo(int orderNo) {
+        this.orderNo = orderNo;
+    }
+    public boolean permitPosting() {
+        return permitPosting;
+    }
+    public void setPermitPosting(boolean permitPosting) {
+        this.permitPosting = permitPosting;
+    }
+    public int getTheme() {
+        return theme;
+    }
+    public void setTheme(int theme) {
+        this.theme = theme;
+    }
+    public String getPreferredAccountsFilter() {
+        return preferredAccountsFilter;
+    }
+    public void setPreferredAccountsFilter(String preferredAccountsFilter) {
+        this.preferredAccountsFilter = preferredAccountsFilter;
+    }
+    public int getFutureDates() {
+        return futureDates;
+    }
+    public void setFutureDates(int futureDates) {
+        this.futureDates = futureDates;
+    }
+    public int getApiVersion() {
+        return apiVersion;
+    }
+    public void setApiVersion(int apiVersion) {
+        this.apiVersion = apiVersion;
+    }
+    public boolean getShowCommodityByDefault() {
+        return showCommodityByDefault;
+    }
+    public void setShowCommodityByDefault(boolean showCommodityByDefault) {
+        this.showCommodityByDefault = showCommodityByDefault;
+    }
+    @NotNull
+    public String getDefaultCommodity() {
+        return defaultCommodity;
+    }
+    public void setDefaultCommodity(@org.jetbrains.annotations.Nullable String defaultCommodity) {
+        this.defaultCommodity = Misc.nullIsEmpty(defaultCommodity);
+    }
+    public boolean getShowCommentsByDefault() {
+        return showCommentsByDefault;
+    }
+    public void setShowCommentsByDefault(boolean showCommentsByDefault) {
+        this.showCommentsByDefault = showCommentsByDefault;
+    }
+    public boolean detectedVersionPre_1_19() {
+        return detectedVersionPre_1_19;
+    }
+    public void setDetectedVersionPre_1_19(boolean detectedVersionPre_1_19) {
+        this.detectedVersionPre_1_19 = detectedVersionPre_1_19;
+    }
+    public int getDetectedVersionMajor() {
+        return detectedVersionMajor;
+    }
+    public void setDetectedVersionMajor(int detectedVersionMajor) {
+        this.detectedVersionMajor = detectedVersionMajor;
+    }
+    public int getDetectedVersionMinor() {
+        return detectedVersionMinor;
+    }
+    public void setDetectedVersionMinor(int detectedVersionMinor) {
+        this.detectedVersionMinor = detectedVersionMinor;
+    }
+    @NonNull
+    @Override
+    public String toString() {
+        return getName();
+    }
+    @Override
+    public boolean equals(@Nullable Object o) {
+        if (!(o instanceof Profile))
+            return false;
+        Profile p = (Profile) o;
+        return id == p.id && Misc.equalStrings(name, p.name) && Misc.equalStrings(uuid, p.uuid) &&
+               Misc.equalStrings(url, p.url) && useAuthentication == p.useAuthentication &&
+               Misc.equalStrings(authUser, p.authUser) &&
+               Misc.equalStrings(authPassword, p.authPassword) && orderNo == p.orderNo &&
+               permitPosting == p.permitPosting && theme == p.theme &&
+               Misc.equalStrings(preferredAccountsFilter, p.preferredAccountsFilter) &&
+               futureDates == p.futureDates && apiVersion == p.apiVersion &&
+               showCommentsByDefault == p.showCommentsByDefault &&
+               Misc.equalStrings(defaultCommodity, p.defaultCommodity) &&
+               showCommentsByDefault == p.showCommentsByDefault &&
+               detectedVersionPre_1_19 == p.detectedVersionPre_1_19 &&
+               detectedVersionMajor == p.detectedVersionMajor &&
+               detectedVersionMinor == p.detectedVersionMinor;
+    }
+    @Transaction
+    public void wipeAllDataSync() {
+        OptionDAO optDao = DB.get()
+                             .getOptionDAO();
+        optDao.deleteSync(optDao.allForProfileSync(id));
+
+        AccountDAO accDao = DB.get()
+                              .getAccountDAO();
+        accDao.deleteSync(accDao.allForProfileSync(id));
+
+        TransactionDAO trnDao = DB.get()
+                                  .getTransactionDAO();
+        trnDao.deleteSync(trnDao.getAllForProfileUnorderedSync(id));
+    }
+    public void wipeAllData() {
+        BaseDAO.runAsync(this::wipeAllDataSync);
+    }
+
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/db/TemplateAccount.java b/app/src/main/java/net/ktnx/mobileledger/db/TemplateAccount.java
new file mode 100644 (file)
index 0000000..949bd9d
--- /dev/null
@@ -0,0 +1,175 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.db;
+
+import androidx.annotation.NonNull;
+import androidx.room.ColumnInfo;
+import androidx.room.Entity;
+import androidx.room.ForeignKey;
+import androidx.room.Index;
+import androidx.room.PrimaryKey;
+
+import org.jetbrains.annotations.NotNull;
+
+@Entity(tableName = "template_accounts",
+        indices = {@Index(name = "fk_template_accounts_template", value = "template_id"),
+                   @Index(name = "fk_template_accounts_currency", value = "currency")
+        }, foreignKeys = {@ForeignKey(childColumns = "template_id", parentColumns = "id",
+                                      entity = TemplateHeader.class, onDelete = ForeignKey.CASCADE,
+                                      onUpdate = ForeignKey.RESTRICT),
+                          @ForeignKey(childColumns = "currency", parentColumns = "id",
+                                      entity = Currency.class, onDelete = ForeignKey.RESTRICT,
+                                      onUpdate = ForeignKey.RESTRICT)
+})
+public class TemplateAccount extends TemplateBase {
+    @PrimaryKey(autoGenerate = true)
+    private long id;
+    @ColumnInfo(name = "template_id")
+    private long templateId;
+    @ColumnInfo(name = "acc")
+    private String accountName;
+    @ColumnInfo(name = "position")
+    @NonNull
+    private Long position;
+    @ColumnInfo(name = "acc_match_group")
+    private Integer accountNameMatchGroup;
+    @ColumnInfo
+    private Long currency;
+    @ColumnInfo(name = "currency_match_group")
+    private Integer currencyMatchGroup;
+    @ColumnInfo(name = "amount")
+    private Float amount;
+    @ColumnInfo(name = "amount_match_group")
+    private Integer amountMatchGroup;
+    @ColumnInfo(name = "comment")
+    private String accountComment;
+    @ColumnInfo(name = "comment_match_group")
+    private Integer accountCommentMatchGroup;
+    @ColumnInfo(name = "negate_amount")
+    private Boolean negateAmount;
+    public TemplateAccount(@NotNull Long id, @NonNull Long templateId, @NonNull Long position) {
+        this.id = id;
+        this.templateId = templateId;
+        this.position = position;
+    }
+    public TemplateAccount(TemplateAccount o) {
+        id = o.id;
+        templateId = o.templateId;
+        accountName = o.accountName;
+        position = o.position;
+        accountNameMatchGroup = o.accountNameMatchGroup;
+        currency = o.currency;
+        currencyMatchGroup = o.currencyMatchGroup;
+        amount = o.amount;
+        amountMatchGroup = o.amountMatchGroup;
+        accountComment = o.accountComment;
+        accountCommentMatchGroup = o.accountCommentMatchGroup;
+        negateAmount = o.negateAmount;
+    }
+    public long getId() {
+        return id;
+    }
+    public void setId(long id) {
+        this.id = id;
+    }
+    public Boolean getNegateAmount() {
+        return negateAmount;
+    }
+    public void setNegateAmount(Boolean negateAmount) {
+        this.negateAmount = negateAmount;
+    }
+    public long getTemplateId() {
+        return templateId;
+    }
+    public void setTemplateId(long templateId) {
+        this.templateId = templateId;
+    }
+    @NonNull
+    public String getAccountName() {
+        return accountName;
+    }
+    public void setAccountName(@NonNull String accountName) {
+        this.accountName = accountName;
+    }
+    @NonNull
+    public Long getPosition() {
+        return position;
+    }
+    public void setPosition(@NonNull Long position) {
+        this.position = position;
+    }
+    public void setPosition(int position) {
+        this.position = (long) position;
+    }
+    public Integer getAccountNameMatchGroup() {
+        return accountNameMatchGroup;
+    }
+    public void setAccountNameMatchGroup(Integer accountNameMatchGroup) {
+        this.accountNameMatchGroup = accountNameMatchGroup;
+    }
+    public Long getCurrency() {
+        return currency;
+    }
+    public void setCurrency(Long currency) {
+        this.currency = currency;
+    }
+    public Currency getCurrencyObject() {
+        if (currency == null || currency <= 0)
+            return null;
+        return DB.get()
+                 .getCurrencyDAO()
+                 .getByIdSync(currency);
+    }
+    public Integer getCurrencyMatchGroup() {
+        return currencyMatchGroup;
+    }
+    public void setCurrencyMatchGroup(Integer currencyMatchGroup) {
+        this.currencyMatchGroup = currencyMatchGroup;
+    }
+    public Float getAmount() {
+        return amount;
+    }
+    public void setAmount(Float amount) {
+        this.amount = amount;
+    }
+    public Integer getAmountMatchGroup() {
+        return amountMatchGroup;
+    }
+    public void setAmountMatchGroup(Integer amountMatchGroup) {
+        this.amountMatchGroup = amountMatchGroup;
+    }
+    public String getAccountComment() {
+        return accountComment;
+    }
+    public void setAccountComment(String accountComment) {
+        this.accountComment = accountComment;
+    }
+    public Integer getAccountCommentMatchGroup() {
+        return accountCommentMatchGroup;
+    }
+    public void setAccountCommentMatchGroup(Integer accountCommentMatchGroup) {
+        this.accountCommentMatchGroup = accountCommentMatchGroup;
+    }
+    public TemplateAccount createDuplicate(TemplateHeader header) {
+        TemplateAccount dup = new TemplateAccount(this);
+        dup.id = 0;
+        dup.templateId = header.getId();
+
+        return dup;
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/db/TemplateBase.java b/app/src/main/java/net/ktnx/mobileledger/db/TemplateBase.java
new file mode 100644 (file)
index 0000000..83a963a
--- /dev/null
@@ -0,0 +1,20 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.db;
+
+public class TemplateBase {}
diff --git a/app/src/main/java/net/ktnx/mobileledger/db/TemplateHeader.java b/app/src/main/java/net/ktnx/mobileledger/db/TemplateHeader.java
new file mode 100644 (file)
index 0000000..994c330
--- /dev/null
@@ -0,0 +1,227 @@
+/*
+ * Copyright © 2022 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.db;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.room.ColumnInfo;
+import androidx.room.Entity;
+import androidx.room.Index;
+import androidx.room.PrimaryKey;
+
+import net.ktnx.mobileledger.utils.Misc;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.UUID;
+
+@Entity(tableName = "templates",
+        indices = {@Index(name = "templates_uuid_idx", unique = true, value = "uuid")})
+public class TemplateHeader extends TemplateBase {
+    @PrimaryKey(autoGenerate = true)
+    private long id;
+    @ColumnInfo(name = "name")
+    @NonNull
+    private String name;
+    @NonNull
+    @ColumnInfo
+    private String uuid;
+    @NonNull
+    @ColumnInfo(name = "regular_expression")
+    private String regularExpression;
+    @ColumnInfo(name = "test_text")
+    private String testText;
+    @ColumnInfo(name = "transaction_description")
+    private String transactionDescription;
+    @ColumnInfo(name = "transaction_description_match_group")
+    private Integer transactionDescriptionMatchGroup;
+    @ColumnInfo(name = "transaction_comment")
+    private String transactionComment;
+    @ColumnInfo(name = "transaction_comment_match_group")
+    private Integer transactionCommentMatchGroup;
+    @ColumnInfo(name = "date_year")
+    private Integer dateYear;
+    @ColumnInfo(name = "date_year_match_group")
+    private Integer dateYearMatchGroup;
+    @ColumnInfo(name = "date_month")
+    private Integer dateMonth;
+    @ColumnInfo(name = "date_month_match_group")
+    private Integer dateMonthMatchGroup;
+    @ColumnInfo(name = "date_day")
+    private Integer dateDay;
+    @ColumnInfo(name = "date_day_match_group")
+    private Integer dateDayMatchGroup;
+    @ColumnInfo(name = "is_fallback")
+    private boolean isFallback;
+    public TemplateHeader(@NotNull Long id, @NonNull String name,
+                          @NonNull String regularExpression) {
+        this.id = id;
+        this.name = name;
+        this.regularExpression = regularExpression;
+        this.uuid = UUID.randomUUID()
+                        .toString();
+    }
+    public TemplateHeader(TemplateHeader origin) {
+        id = origin.id;
+        name = origin.name;
+        uuid = origin.uuid;
+        regularExpression = origin.regularExpression;
+        testText = origin.testText;
+        transactionDescription = origin.transactionDescription;
+        transactionDescriptionMatchGroup = origin.transactionDescriptionMatchGroup;
+        transactionComment = origin.transactionComment;
+        transactionCommentMatchGroup = origin.transactionCommentMatchGroup;
+        dateYear = origin.dateYear;
+        dateYearMatchGroup = origin.dateYearMatchGroup;
+        dateMonth = origin.dateMonth;
+        dateMonthMatchGroup = origin.dateMonthMatchGroup;
+        dateDay = origin.dateDay;
+        dateDayMatchGroup = origin.dateDayMatchGroup;
+        isFallback = origin.isFallback;
+    }
+    @NonNull
+    public String getUuid() {
+        return uuid;
+    }
+    public void setUuid(@NonNull String uuid) {
+        this.uuid = uuid;
+    }
+    public boolean isFallback() {
+        return isFallback;
+    }
+    public void setFallback(boolean fallback) {
+        isFallback = fallback;
+    }
+    public String getTestText() {
+        return testText;
+    }
+    public void setTestText(String testText) {
+        this.testText = testText;
+    }
+    public Integer getTransactionDescriptionMatchGroup() {
+        return transactionDescriptionMatchGroup;
+    }
+    public void setTransactionDescriptionMatchGroup(Integer transactionDescriptionMatchGroup) {
+        this.transactionDescriptionMatchGroup = transactionDescriptionMatchGroup;
+    }
+    public Integer getTransactionCommentMatchGroup() {
+        return transactionCommentMatchGroup;
+    }
+    public void setTransactionCommentMatchGroup(Integer transactionCommentMatchGroup) {
+        this.transactionCommentMatchGroup = transactionCommentMatchGroup;
+    }
+    public Integer getDateYear() {
+        return dateYear;
+    }
+    public void setDateYear(Integer dateYear) {
+        this.dateYear = dateYear;
+    }
+    public Integer getDateMonth() {
+        return dateMonth;
+    }
+    public void setDateMonth(Integer dateMonth) {
+        this.dateMonth = dateMonth;
+    }
+    public Integer getDateDay() {
+        return dateDay;
+    }
+    public void setDateDay(Integer dateDay) {
+        this.dateDay = dateDay;
+    }
+    public long getId() {
+        return id;
+    }
+    public void setId(long id) {
+        this.id = id;
+    }
+    @NonNull
+    public String getName() {
+        return name;
+    }
+    public void setName(@NonNull String name) {
+        this.name = name;
+    }
+    @NonNull
+    public String getRegularExpression() {
+        return regularExpression;
+    }
+    public void setRegularExpression(@NonNull String regularExpression) {
+        this.regularExpression = regularExpression;
+    }
+    public String getTransactionDescription() {
+        return transactionDescription;
+    }
+    public void setTransactionDescription(String transactionDescription) {
+        this.transactionDescription = transactionDescription;
+    }
+    public String getTransactionComment() {
+        return transactionComment;
+    }
+    public void setTransactionComment(String transactionComment) {
+        this.transactionComment = transactionComment;
+    }
+    public Integer getDateYearMatchGroup() {
+        return dateYearMatchGroup;
+    }
+    public void setDateYearMatchGroup(Integer dateYearMatchGroup) {
+        this.dateYearMatchGroup = dateYearMatchGroup;
+    }
+    public Integer getDateMonthMatchGroup() {
+        return dateMonthMatchGroup;
+    }
+    public void setDateMonthMatchGroup(Integer dateMonthMatchGroup) {
+        this.dateMonthMatchGroup = dateMonthMatchGroup;
+    }
+    public Integer getDateDayMatchGroup() {
+        return dateDayMatchGroup;
+    }
+    public void setDateDayMatchGroup(Integer dateDayMatchGroup) {
+        this.dateDayMatchGroup = dateDayMatchGroup;
+    }
+    @Override
+    public boolean equals(@Nullable Object obj) {
+        if (obj == null)
+            return false;
+        if (!(obj instanceof TemplateHeader))
+            return false;
+
+        TemplateHeader o = (TemplateHeader) obj;
+
+        return Misc.equalLongs(id, o.id) && Misc.equalStrings(name, o.name) &&
+               Misc.equalStrings(regularExpression, o.regularExpression) &&
+               Misc.equalStrings(transactionDescription, o.transactionDescription) &&
+               Misc.equalStrings(transactionComment, o.transactionComment) &&
+               Misc.equalIntegers(transactionDescriptionMatchGroup,
+                       o.transactionDescriptionMatchGroup) &&
+               Misc.equalIntegers(transactionCommentMatchGroup, o.transactionCommentMatchGroup) &&
+               Misc.equalIntegers(dateDay, o.dateDay) &&
+               Misc.equalIntegers(dateDayMatchGroup, o.dateDayMatchGroup) &&
+               Misc.equalIntegers(dateMonth, o.dateMonth) &&
+               Misc.equalIntegers(dateMonthMatchGroup, o.dateMonthMatchGroup) &&
+               Misc.equalIntegers(dateYear, o.dateYear) &&
+               Misc.equalIntegers(dateYearMatchGroup, o.dateYearMatchGroup);
+    }
+    public TemplateHeader createDuplicate() {
+        TemplateHeader dup = new TemplateHeader(this);
+        dup.id = 0;
+        dup.uuid = UUID.randomUUID()
+                       .toString();
+
+        return dup;
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/db/TemplateWithAccounts.java b/app/src/main/java/net/ktnx/mobileledger/db/TemplateWithAccounts.java
new file mode 100644 (file)
index 0000000..a96236e
--- /dev/null
@@ -0,0 +1,55 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.db;
+
+import androidx.room.Embedded;
+import androidx.room.Relation;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class TemplateWithAccounts {
+    @Embedded
+    public TemplateHeader header;
+    @Relation(parentColumn = "id", entityColumn = "template_id")
+    public List<TemplateAccount> accounts;
+
+    public static TemplateWithAccounts from(TemplateWithAccounts o) {
+        TemplateWithAccounts result = new TemplateWithAccounts();
+        result.header = new TemplateHeader(o.header);
+        result.accounts = new ArrayList<>();
+        for (TemplateAccount acc : o.accounts) {
+            result.accounts.add(new TemplateAccount(acc));
+        }
+
+        return result;
+    }
+    public Long getId() {
+        return header.getId();
+    }
+    public TemplateWithAccounts createDuplicate() {
+        TemplateWithAccounts result = new TemplateWithAccounts();
+        result.header = header.createDuplicate();
+        result.accounts = new ArrayList<>();
+        for (TemplateAccount acc : accounts) {
+            result.accounts.add(acc.createDuplicate(result.header));
+        }
+
+        return result;
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/db/Transaction.java b/app/src/main/java/net/ktnx/mobileledger/db/Transaction.java
new file mode 100644 (file)
index 0000000..8b98f92
--- /dev/null
@@ -0,0 +1,153 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.db;
+
+import androidx.annotation.NonNull;
+import androidx.room.ColumnInfo;
+import androidx.room.Entity;
+import androidx.room.ForeignKey;
+import androidx.room.Index;
+import androidx.room.PrimaryKey;
+
+import org.jetbrains.annotations.NotNull;
+
+/*
+create table transactions(profile varchar not null, id integer not null, data_hash varchar not
+null, year integer not null, month integer not null, day integer not null, description varchar
+collate NOCASE not null, comment varchar, generation integer default 0, primary key(profile,id));
+create unique index un_transactions_data_hash on transactions(profile,data_hash);
+create index idx_transaction_description on transactions(description);
+ */
+@Entity(tableName = "transactions", foreignKeys = {
+        @ForeignKey(entity = Profile.class, parentColumns = "id", childColumns = "profile_id",
+                    onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.RESTRICT)
+}, indices = {@Index(name = "un_transactions_ledger_id", unique = true,
+                     value = {"profile_id", "ledger_id"}),
+              @Index(name = "idx_transaction_description", value = "description"),
+              @Index(name = "fk_transaction_profile", value = "profile_id")
+})
+public class Transaction {
+    @ColumnInfo
+    @PrimaryKey(autoGenerate = true)
+    long id;
+    @ColumnInfo(name = "ledger_id")
+    long ledgerId;
+    @ColumnInfo(name = "profile_id")
+    private long profileId;
+    @ColumnInfo(name = "data_hash")
+    @NonNull
+    private String dataHash;
+    @ColumnInfo
+    private int year;
+    @ColumnInfo
+    private int month;
+    @ColumnInfo
+    private int day;
+    @ColumnInfo(collate = ColumnInfo.NOCASE)
+    @NonNull
+    private String description;
+    @ColumnInfo(name = "description_uc")
+    @NonNull
+    private String descriptionUpper;
+    @ColumnInfo
+    private String comment;
+    @ColumnInfo
+    private long generation = 0;
+    @NonNull
+    public String getDescriptionUpper() {
+        return descriptionUpper;
+    }
+    public void setDescriptionUpper(@NonNull String descriptionUpper) {
+        this.descriptionUpper = descriptionUpper;
+    }
+    public long getLedgerId() {
+        return ledgerId;
+    }
+    public void setLedgerId(long ledgerId) {
+        this.ledgerId = ledgerId;
+    }
+    public long getProfileId() {
+        return profileId;
+    }
+    public void setProfileId(long profileId) {
+        this.profileId = profileId;
+    }
+    public long getId() {
+        return id;
+    }
+    public void setId(long id) {
+        this.id = id;
+    }
+    public String getDataHash() {
+        return dataHash;
+    }
+    public void setDataHash(@NotNull String dataHash) {
+        this.dataHash = dataHash;
+    }
+    public int getYear() {
+        return year;
+    }
+    public void setYear(int year) {
+        this.year = year;
+    }
+    public int getMonth() {
+        return month;
+    }
+    public void setMonth(int month) {
+        this.month = month;
+    }
+    public int getDay() {
+        return day;
+    }
+    public void setDay(int day) {
+        this.day = day;
+    }
+    public String getDescription() {
+        return description;
+    }
+    public void setDescription(String description) {
+        this.description = description;
+        setDescriptionUpper(description.toUpperCase());
+    }
+    public String getComment() {
+        return comment;
+    }
+    public void setComment(String comment) {
+        this.comment = comment;
+    }
+    public long getGeneration() {
+        return generation;
+    }
+    public void setGeneration(long generation) {
+        this.generation = generation;
+    }
+
+    public void copyDataFrom(Transaction o) {
+        // id = o.id;
+        ledgerId = o.ledgerId;
+        profileId = o.profileId;
+        dataHash = o.dataHash;
+        year = o.year;
+        month = o.month;
+        day = o.day;
+        description = o.description;
+        descriptionUpper = o.description.toUpperCase();
+        comment = o.comment;
+        generation = o.generation;
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/db/TransactionAccount.java b/app/src/main/java/net/ktnx/mobileledger/db/TransactionAccount.java
new file mode 100644 (file)
index 0000000..d6417cd
--- /dev/null
@@ -0,0 +1,119 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.db;
+
+import androidx.annotation.NonNull;
+import androidx.room.ColumnInfo;
+import androidx.room.Entity;
+import androidx.room.ForeignKey;
+import androidx.room.Index;
+import androidx.room.PrimaryKey;
+
+import net.ktnx.mobileledger.utils.Misc;
+
+@Entity(tableName = "transaction_accounts", foreignKeys = {
+        @ForeignKey(entity = Transaction.class, parentColumns = {"id"},
+                    childColumns = {"transaction_id"}, onDelete = ForeignKey.CASCADE,
+                    onUpdate = ForeignKey.RESTRICT)
+}, indices = {@Index(name = "fk_trans_acc_trans", value = {"transaction_id"}),
+              @Index(name = "un_transaction_accounts", unique = true,
+                     value = {"transaction_id", "order_no"})
+})
+public class TransactionAccount {
+    @ColumnInfo
+    @PrimaryKey(autoGenerate = true)
+    private long id;
+    @ColumnInfo(name = "transaction_id")
+    private long transactionId;
+    @ColumnInfo(name = "order_no")
+    private int orderNo;
+    @ColumnInfo(name = "account_name")
+    @NonNull
+    private String accountName;
+    @ColumnInfo(defaultValue = "")
+    @NonNull
+    private String currency = "";
+    @ColumnInfo
+    private float amount;
+    @ColumnInfo
+    private String comment;
+    @ColumnInfo(defaultValue = "0")
+    private long generation = 0;
+    public long getId() {
+        return id;
+    }
+    public void setId(long id) {
+        this.id = id;
+    }
+    @NonNull
+    public long getTransactionId() {
+        return transactionId;
+    }
+    public void setTransactionId(long transactionId) {
+        this.transactionId = transactionId;
+    }
+    public int getOrderNo() {
+        return orderNo;
+    }
+    public void setOrderNo(int orderNo) {
+        this.orderNo = orderNo;
+    }
+    @NonNull
+    public String getAccountName() {
+        return accountName;
+    }
+    public void setAccountName(@NonNull String accountName) {
+        this.accountName = accountName;
+    }
+    @NonNull
+    public String getCurrency() {
+        return currency;
+    }
+    public void setCurrency(@NonNull String currency) {
+        this.currency = currency;
+    }
+    public float getAmount() {
+        return amount;
+    }
+    public void setAmount(float amount) {
+        this.amount = amount;
+    }
+    public String getComment() {
+        return comment;
+    }
+    public void setComment(String comment) {
+        this.comment = comment;
+    }
+    public long getGeneration() {
+        return generation;
+    }
+    public void setGeneration(long generation) {
+        this.generation = generation;
+    }
+
+    public void copyDataFrom(TransactionAccount o) {
+        // id = o.id
+        transactionId = o.transactionId;
+        orderNo = o.orderNo;
+        accountName = o.accountName;
+        currency = Misc.nullIsEmpty(o.currency);
+        amount = o.amount;
+        comment = o.comment;
+        generation = o.generation;
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/db/TransactionDescriptionAutocompleteAdapter.java b/app/src/main/java/net/ktnx/mobileledger/db/TransactionDescriptionAutocompleteAdapter.java
new file mode 100644 (file)
index 0000000..be4b0a8
--- /dev/null
@@ -0,0 +1,76 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.db;
+
+import android.content.Context;
+import android.widget.ArrayAdapter;
+import android.widget.Filter;
+
+import androidx.annotation.NonNull;
+
+import net.ktnx.mobileledger.dao.TransactionDAO;
+import net.ktnx.mobileledger.utils.Logger;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class TransactionDescriptionAutocompleteAdapter extends ArrayAdapter<String> {
+    private final TransactionFilter filter = new TransactionFilter();
+    private final TransactionDAO dao = DB.get()
+                                         .getTransactionDAO();
+    public TransactionDescriptionAutocompleteAdapter(Context context) {
+        super(context, android.R.layout.simple_dropdown_item_1line, new ArrayList<>());
+    }
+    @NonNull
+    @Override
+    public Filter getFilter() {
+        return filter;
+    }
+    class TransactionFilter extends Filter {
+        @Override
+        protected FilterResults performFiltering(CharSequence constraint) {
+            FilterResults results = new FilterResults();
+            if (constraint == null) {
+                results.count = 0;
+                return results;
+            }
+
+            Logger.debug("acc", String.format("Looking for description '%s'", constraint));
+            final List<String> matches = TransactionDAO.unbox(dao.lookupDescriptionSync(
+                    String.valueOf(constraint)
+                          .toUpperCase()));
+            results.values = matches;
+            results.count = matches.size();
+
+            return results;
+        }
+        @Override
+        @SuppressWarnings("unchecked")
+        protected void publishResults(CharSequence constraint, FilterResults results) {
+            if (results.values == null) {
+                notifyDataSetInvalidated();
+            }
+            else {
+                setNotifyOnChange(false);
+                clear();
+                addAll((List<String>) results.values);
+                notifyDataSetChanged();
+            }
+        }
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/db/TransactionWithAccounts.java b/app/src/main/java/net/ktnx/mobileledger/db/TransactionWithAccounts.java
new file mode 100644 (file)
index 0000000..5e91e4e
--- /dev/null
@@ -0,0 +1,30 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.db;
+
+import androidx.room.Embedded;
+import androidx.room.Relation;
+
+import java.util.List;
+
+public class TransactionWithAccounts {
+    @Embedded
+    public Transaction transaction;
+    @Relation(parentColumn = "id", entityColumn = "transaction_id")
+    public List<TransactionAccount> accounts;
+}
index 17b578c4987ef644435e47413591b36d792daf1e..7d281650caa41f8d7f57817c1f2dc3c1a74783e8 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2019 Damyan Ivanov.
+ * Copyright © 2021 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
 
 package net.ktnx.mobileledger.err;
 
-public class HTTPException extends Throwable {
+public class HTTPException extends Exception {
     private final int responseCode;
-    private final String responseMessage;
     public int getResponseCode() {
         return responseCode;
     }
-    public String getResponseMessage() {
-        return responseMessage;
-    }
     public HTTPException(int responseCode, String responseMessage) {
+        super(responseMessage);
         this.responseCode = responseCode;
-        this.responseMessage = responseMessage;
     }
 }
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/API.java b/app/src/main/java/net/ktnx/mobileledger/json/API.java
new file mode 100644 (file)
index 0000000..136bdf5
--- /dev/null
@@ -0,0 +1,83 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json;
+
+import android.content.res.Resources;
+import android.util.SparseArray;
+
+import net.ktnx.mobileledger.R;
+
+public enum API {
+    auto(0), html(-1), v1_14(-2), v1_15(-3), v1_19_1(-4), v1_23(-5);
+    private static final SparseArray<API> map = new SparseArray<>();
+    public static API[] allVersions = {v1_23, v1_19_1, v1_15, v1_14};
+
+    static {
+        for (API item : API.values()) {
+            map.put(item.value, item);
+        }
+    }
+
+    private final int value;
+
+    API(int value) {
+        this.value = value;
+    }
+    public static API valueOf(int i) {
+        return map.get(i, auto);
+    }
+    public int toInt() {
+        return this.value;
+    }
+    public String getDescription(Resources resources) {
+        switch (this) {
+            case auto:
+                return resources.getString(R.string.api_auto);
+            case html:
+                return resources.getString(R.string.api_html);
+            case v1_14:
+                return resources.getString(R.string.api_1_14);
+            case v1_15:
+                return resources.getString(R.string.api_1_15);
+            case v1_19_1:
+                return resources.getString(R.string.api_1_19_1);
+            case v1_23:
+                return resources.getString(R.string.api_1_23);
+            default:
+                throw new IllegalStateException("Unexpected value: " + value);
+        }
+    }
+    public String getDescription() {
+        switch (this) {
+            case auto:
+                return "(automatic)";
+            case html:
+                return "(HTML)";
+            case v1_14:
+                return "1.14";
+            case v1_15:
+                return "1.15";
+            case v1_19_1:
+                return "1.19.1";
+            case v1_23:
+                return "1.23";
+            default:
+                throw new IllegalStateException("Unexpected value: " + this);
+        }
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/AccountListParser.java b/app/src/main/java/net/ktnx/mobileledger/json/AccountListParser.java
new file mode 100644 (file)
index 0000000..baeeb2e
--- /dev/null
@@ -0,0 +1,67 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json;
+
+import com.fasterxml.jackson.databind.MappingIterator;
+
+import net.ktnx.mobileledger.async.RetrieveTransactionsTask;
+import net.ktnx.mobileledger.model.LedgerAccount;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.HashMap;
+
+import static net.ktnx.mobileledger.utils.Logger.debug;
+
+abstract public class AccountListParser {
+    protected MappingIterator<net.ktnx.mobileledger.json.ParsedLedgerAccount> iterator;
+    public static AccountListParser forApiVersion(API version, InputStream input)
+            throws IOException {
+        switch (version) {
+            case v1_14:
+                return new net.ktnx.mobileledger.json.v1_14.AccountListParser(input);
+            case v1_15:
+                return new net.ktnx.mobileledger.json.v1_15.AccountListParser(input);
+            case v1_19_1:
+                return new net.ktnx.mobileledger.json.v1_19_1.AccountListParser(input);
+            case v1_23:
+                return new net.ktnx.mobileledger.json.v1_23.AccountListParser(input);
+            default:
+                throw new RuntimeException("Unsupported version " + version.toString());
+        }
+
+    }
+    public abstract API getApiVersion();
+    public LedgerAccount nextAccount(RetrieveTransactionsTask task,
+                                     HashMap<String, LedgerAccount> map) {
+        if (!iterator.hasNext())
+            return null;
+
+        LedgerAccount next = iterator.next()
+                                     .toLedgerAccount(task, map);
+
+        if (next.getName()
+                .equalsIgnoreCase("root"))
+            return nextAccount(task, map);
+
+        debug("accounts", String.format("Got account '%s' [%s]", next.getName(),
+                getApiVersion().getDescription()));
+        return next;
+    }
+
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/ApiNotSupportedException.java b/app/src/main/java/net/ktnx/mobileledger/json/ApiNotSupportedException.java
new file mode 100644 (file)
index 0000000..7bf8451
--- /dev/null
@@ -0,0 +1,38 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json;
+
+import androidx.annotation.Nullable;
+
+public class ApiNotSupportedException extends Throwable {
+    public ApiNotSupportedException() {
+    }
+    public ApiNotSupportedException(@Nullable String message) {
+        super(message);
+    }
+    public ApiNotSupportedException(@Nullable String message, @Nullable Throwable cause) {
+        super(message, cause);
+    }
+    public ApiNotSupportedException(@Nullable Throwable cause) {
+        super(cause);
+    }
+    public ApiNotSupportedException(@Nullable String message, @Nullable Throwable cause,
+                                    boolean enableSuppression, boolean writableStackTrace) {
+        super(message, cause, enableSuppression, writableStackTrace);
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/Gateway.java b/app/src/main/java/net/ktnx/mobileledger/json/Gateway.java
new file mode 100644 (file)
index 0000000..521e4c9
--- /dev/null
@@ -0,0 +1,42 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+
+import net.ktnx.mobileledger.model.LedgerTransaction;
+
+abstract public class Gateway {
+    public static Gateway forApiVersion(API apiVersion) {
+        switch (apiVersion) {
+            case v1_14:
+                return new net.ktnx.mobileledger.json.v1_14.Gateway();
+            case v1_15:
+                return new net.ktnx.mobileledger.json.v1_15.Gateway();
+            case v1_19_1:
+                return new net.ktnx.mobileledger.json.v1_19_1.Gateway();
+            case v1_23:
+                return new net.ktnx.mobileledger.json.v1_23.Gateway();
+            default:
+                throw new RuntimeException(
+                        "JSON API version " + apiVersion + " save implementation missing");
+        }
+    }
+    public abstract String transactionSaveRequest(LedgerTransaction ledgerTransaction)
+            throws JsonProcessingException;
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/ParsedBalance.java b/app/src/main/java/net/ktnx/mobileledger/json/ParsedBalance.java
new file mode 100644 (file)
index 0000000..49ee26d
--- /dev/null
@@ -0,0 +1,42 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json;
+
+import androidx.annotation.NonNull;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class ParsedBalance {
+    private ParsedQuantity aquantity;
+    private String acommodity;
+    public ParsedQuantity getAquantity() {
+        return aquantity;
+    }
+    public void setAquantity(ParsedQuantity aquantity) {
+        this.aquantity = aquantity;
+    }
+    @NonNull
+    public String getAcommodity() {
+        return (acommodity == null) ? "" : acommodity;
+    }
+    public void setAcommodity(String acommodity) {
+        this.acommodity = acommodity;
+    }
+
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/ParsedLedgerAccount.java b/app/src/main/java/net/ktnx/mobileledger/json/ParsedLedgerAccount.java
new file mode 100644 (file)
index 0000000..e0b0d34
--- /dev/null
@@ -0,0 +1,110 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json;
+
+import net.ktnx.mobileledger.async.RetrieveTransactionsTask;
+import net.ktnx.mobileledger.model.LedgerAccount;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+
+public abstract class ParsedLedgerAccount {
+    private String aname;
+    private int anumpostings;
+    public abstract List<SimpleBalance> getSimpleBalance();
+    public String getAname() {
+        return aname;
+    }
+    public void setAname(String aname) {
+        this.aname = aname;
+    }
+    public int getAnumpostings() {
+        return anumpostings;
+    }
+    public void setAnumpostings(int anumpostings) {
+        this.anumpostings = anumpostings;
+    }
+    public LedgerAccount toLedgerAccount(RetrieveTransactionsTask task,
+                                         HashMap<String, LedgerAccount> map) {
+        task.addNumberOfPostings(getAnumpostings());
+        final String accName = getAname();
+        LedgerAccount acc = map.get(accName);
+        if (acc != null)
+            throw new RuntimeException(
+                    String.format("Account '%s' already present", acc.getName()));
+        String parentName = LedgerAccount.extractParentName(accName);
+        ArrayList<LedgerAccount> createdParents = new ArrayList<>();
+        LedgerAccount parent;
+        if (parentName == null) {
+            parent = null;
+        }
+        else {
+            parent = task.ensureAccountExists(parentName, map, createdParents);
+            parent.setHasSubAccounts(true);
+        }
+        acc = new LedgerAccount(accName, parent);
+        map.put(accName, acc);
+
+        String lastCurrency = null;
+        float lastCurrencyAmount = 0;
+        for (SimpleBalance b : getSimpleBalance()) {
+            task.throwIfCancelled();
+            final String currency = b.getCommodity();
+            final float amount = b.getAmount();
+            if (currency.equals(lastCurrency)) {
+                lastCurrencyAmount += amount;
+            }
+            else {
+                if (lastCurrency != null) {
+                    acc.addAmount(lastCurrencyAmount, lastCurrency);
+                }
+                lastCurrency = currency;
+                lastCurrencyAmount = amount;
+            }
+        }
+        if (lastCurrency != null) {
+            acc.addAmount(lastCurrencyAmount, lastCurrency);
+        }
+        for (LedgerAccount p : createdParents)
+            acc.propagateAmountsTo(p);
+
+        return acc;
+    }
+
+    static public class SimpleBalance {
+        private String commodity;
+        private float amount;
+        public SimpleBalance(String commodity, float amount) {
+            this.commodity = commodity;
+            this.amount = amount;
+        }
+        public String getCommodity() {
+            return commodity;
+        }
+        public void setCommodity(String commodity) {
+            this.commodity = commodity;
+        }
+        public float getAmount() {
+            return amount;
+        }
+        public void setAmount(float amount) {
+            this.amount = amount;
+        }
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/ParsedPrice.java b/app/src/main/java/net/ktnx/mobileledger/json/ParsedPrice.java
new file mode 100644 (file)
index 0000000..3959d14
--- /dev/null
@@ -0,0 +1,81 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json;
+
+import net.ktnx.mobileledger.json.v1_15.ParsedQuantity;
+import net.ktnx.mobileledger.json.v1_15.ParsedStyle;
+
+public class ParsedPrice {
+    private String tag;
+    private Contents contents;
+    public ParsedPrice() {
+        tag = "NoPrice";
+    }
+    public Contents getContents() {
+        return contents;
+    }
+    public void setContents(Contents contents) {
+        this.contents = contents;
+    }
+    public String getTag() {
+        return tag;
+    }
+    public void setTag(String tag) {
+        this.tag = tag;
+    }
+    private static class Contents {
+        private ParsedPrice aprice;
+        private net.ktnx.mobileledger.json.v1_15.ParsedQuantity aquantity;
+        private String acommodity;
+        private boolean aismultiplier;
+        private net.ktnx.mobileledger.json.v1_15.ParsedStyle astyle;
+        public Contents() {
+            acommodity = "";
+        }
+        public ParsedPrice getAprice() {
+            return aprice;
+        }
+        public void setAprice(ParsedPrice aprice) {
+            this.aprice = aprice;
+        }
+        public net.ktnx.mobileledger.json.v1_15.ParsedQuantity getAquantity() {
+            return aquantity;
+        }
+        public void setAquantity(ParsedQuantity aquantity) {
+            this.aquantity = aquantity;
+        }
+        public String getAcommodity() {
+            return acommodity;
+        }
+        public void setAcommodity(String acommodity) {
+            this.acommodity = acommodity;
+        }
+        public boolean isAismultiplier() {
+            return aismultiplier;
+        }
+        public void setAismultiplier(boolean aismultiplier) {
+            this.aismultiplier = aismultiplier;
+        }
+        public net.ktnx.mobileledger.json.v1_15.ParsedStyle getAstyle() {
+            return astyle;
+        }
+        public void setAstyle(ParsedStyle astyle) {
+            this.astyle = astyle;
+        }
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/ParsedQuantity.java b/app/src/main/java/net/ktnx/mobileledger/json/ParsedQuantity.java
new file mode 100644 (file)
index 0000000..2068479
--- /dev/null
@@ -0,0 +1,58 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class ParsedQuantity {
+    private long decimalMantissa;
+    private int decimalPlaces;
+    public ParsedQuantity() {
+    }
+    public ParsedQuantity(String input) {
+        parseString(input);
+    }
+    public long getDecimalMantissa() {
+        return decimalMantissa;
+    }
+    public void setDecimalMantissa(long decimalMantissa) {
+        this.decimalMantissa = decimalMantissa;
+    }
+    public int getDecimalPlaces() {
+        return decimalPlaces;
+    }
+    public void setDecimalPlaces(int decimalPlaces) {
+        this.decimalPlaces = decimalPlaces;
+    }
+    public float asFloat() {
+        return (float) (decimalMantissa * Math.pow(10, -decimalPlaces));
+    }
+    public void parseString(String input) {
+        int pointPos = input.indexOf('.');
+        if (pointPos >= 0) {
+            String integral = input.replace(".", "");
+            decimalMantissa = Long.parseLong(integral);
+            decimalPlaces = input.length() - pointPos - 1;
+        }
+        else {
+            decimalMantissa = Long.parseLong(input);
+            decimalPlaces = 0;
+        }
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/ParsedStyle.java b/app/src/main/java/net/ktnx/mobileledger/json/ParsedStyle.java
new file mode 100644 (file)
index 0000000..c22cd3f
--- /dev/null
@@ -0,0 +1,54 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class ParsedStyle {
+    private char asdecimalpoint;
+    private char ascommodityside;
+    private int digitgroups;
+    private boolean ascommodityspaced;
+    public ParsedStyle() {
+    }
+    public char getAsdecimalpoint() {
+        return asdecimalpoint;
+    }
+    public void setAsdecimalpoint(char asdecimalpoint) {
+        this.asdecimalpoint = asdecimalpoint;
+    }
+    public char getAscommodityside() {
+        return ascommodityside;
+    }
+    public void setAscommodityside(char ascommodityside) {
+        this.ascommodityside = ascommodityside;
+    }
+    public int getDigitgroups() {
+        return digitgroups;
+    }
+    public void setDigitgroups(int digitgroups) {
+        this.digitgroups = digitgroups;
+    }
+    public boolean isAscommodityspaced() {
+        return ascommodityspaced;
+    }
+    public void setAscommodityspaced(boolean ascommodityspaced) {
+        this.ascommodityspaced = ascommodityspaced;
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/TransactionListParser.java b/app/src/main/java/net/ktnx/mobileledger/json/TransactionListParser.java
new file mode 100644 (file)
index 0000000..20bf08b
--- /dev/null
@@ -0,0 +1,44 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json;
+
+import net.ktnx.mobileledger.model.LedgerTransaction;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.text.ParseException;
+
+public abstract class TransactionListParser {
+    public static TransactionListParser forApiVersion(API apiVersion, InputStream input)
+            throws IOException {
+        switch (apiVersion) {
+            case v1_14:
+                return new net.ktnx.mobileledger.json.v1_14.TransactionListParser(input);
+            case v1_15:
+                return new net.ktnx.mobileledger.json.v1_15.TransactionListParser(input);
+            case v1_19_1:
+                return new net.ktnx.mobileledger.json.v1_19_1.TransactionListParser(input);
+            case v1_23:
+                return new net.ktnx.mobileledger.json.v1_23.TransactionListParser(input);
+            default:
+                throw new RuntimeException("Unsupported version " + apiVersion.toString());
+        }
+
+    }
+    abstract public LedgerTransaction nextTransaction() throws ParseException;
+}
index f3773a52850d5c354c8a9c163469a80fb27bc7df..0c589520d4904a27ce1cfce2ca238e496d44c22f 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2019 Damyan Ivanov.
+ * Copyright © 2020 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
 
 package net.ktnx.mobileledger.json.v1_14;
 
-import com.fasterxml.jackson.databind.MappingIterator;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.ObjectReader;
 
+import net.ktnx.mobileledger.json.API;
+
 import java.io.IOException;
 import java.io.InputStream;
 
-import static net.ktnx.mobileledger.utils.Logger.debug;
-
-public class AccountListParser {
-
-    private final MappingIterator<ParsedLedgerAccount> iterator;
+public class AccountListParser extends net.ktnx.mobileledger.json.AccountListParser {
 
     public AccountListParser(InputStream input) throws IOException {
         ObjectMapper mapper = new ObjectMapper();
@@ -36,14 +33,8 @@ public class AccountListParser {
 
         iterator = reader.readValues(input);
     }
-    public ParsedLedgerAccount nextAccount() {
-        if (!iterator.hasNext()) return null;
-
-        ParsedLedgerAccount next = iterator.next();
-
-        if (next.getAname().equalsIgnoreCase("root")) return nextAccount();
-
-        debug("accounts", String.format("Got account '%s'", next.getAname()));
-        return next;
+    @Override
+    public API getApiVersion() {
+        return API.v1_14;
     }
 }
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/v1_14/Gateway.java b/app/src/main/java/net/ktnx/mobileledger/json/v1_14/Gateway.java
new file mode 100644 (file)
index 0000000..25712af
--- /dev/null
@@ -0,0 +1,38 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json.v1_14;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.ObjectWriter;
+
+import net.ktnx.mobileledger.model.LedgerTransaction;
+
+public class Gateway extends net.ktnx.mobileledger.json.Gateway {
+    @Override
+    public String transactionSaveRequest(LedgerTransaction ledgerTransaction)
+            throws JsonProcessingException {
+        net.ktnx.mobileledger.json.v1_14.ParsedLedgerTransaction jsonTransaction =
+                net.ktnx.mobileledger.json.v1_14.ParsedLedgerTransaction.fromLedgerTransaction(
+                        ledgerTransaction);
+        ObjectMapper mapper = new ObjectMapper();
+        ObjectWriter writer =
+                mapper.writerFor(net.ktnx.mobileledger.json.v1_14.ParsedLedgerTransaction.class);
+        return writer.writeValueAsString(jsonTransaction);
+    }
+}
index 39cd1908ff308f1f450b9453c577d98180e38835..cb8b5f5fbe3bdf9ab225e8f258e326bcc785c0dd 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2019 Damyan Ivanov.
+ * Copyright © 2020 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
 
 package net.ktnx.mobileledger.json.v1_14;
 
-import androidx.annotation.NonNull;
-
 import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
 
 @JsonIgnoreProperties(ignoreUnknown = true)
-public class ParsedBalance {
-    private ParsedQuantity aquantity;
-    private String acommodity;
+public class ParsedBalance extends net.ktnx.mobileledger.json.ParsedBalance {
     private ParsedStyle astyle;
     public ParsedBalance() {
     }
-    public ParsedQuantity getAquantity() {
-        return aquantity;
-    }
-    public void setAquantity(ParsedQuantity aquantity) {
-        this.aquantity = aquantity;
-    }
-    @NonNull
-    public String getAcommodity() {
-        return (acommodity == null) ? "" : acommodity;
-    }
-    public void setAcommodity(String acommodity) {
-        this.acommodity = acommodity;
-    }
     public ParsedStyle getAstyle() {
         return astyle;
     }
index ab982b4e52883872f4cb6f130900a0c814ad141c..476e9cd713936c8c3474151b5b2e00216ac87424 100644 (file)
@@ -19,22 +19,15 @@ package net.ktnx.mobileledger.json.v1_14;
 
 import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
 
+import java.util.ArrayList;
 import java.util.List;
 
 @JsonIgnoreProperties(ignoreUnknown = true)
-public class ParsedLedgerAccount {
+public class ParsedLedgerAccount extends net.ktnx.mobileledger.json.ParsedLedgerAccount {
     private List<ParsedBalance> aebalance;
     private List<ParsedBalance> aibalance;
-    private String aname;
-    private int anumpostings;
     public ParsedLedgerAccount() {
     }
-    public int getAnumpostings() {
-        return anumpostings;
-    }
-    public void setAnumpostings(int anumpostings) {
-        this.anumpostings = anumpostings;
-    }
     public List<ParsedBalance> getAebalance() {
         return aebalance;
     }
@@ -47,11 +40,14 @@ public class ParsedLedgerAccount {
     public void setAibalance(List<ParsedBalance> aibalance) {
         this.aibalance = aibalance;
     }
-    public String getAname() {
-        return aname;
-    }
-    public void setAname(String aname) {
-        this.aname = aname;
-    }
+    @Override
+    public List<SimpleBalance> getSimpleBalance() {
+        List<SimpleBalance> result = new ArrayList<SimpleBalance>();
+        for (ParsedBalance b : getAibalance()) {
+            result.add(new SimpleBalance(b.getAcommodity(), b.getAquantity()
+                                                             .asFloat()));
+        }
 
+        return result;
+    }
 }
index eec404b8326c32216aa8f73d3cfbea4694756ea5..6c8841595e15989922ff6b74beafef2c598f6775 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2020 Damyan Ivanov.
+ * Copyright © 2021 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
@@ -48,7 +48,7 @@ public class ParsedLedgerTransaction implements net.ktnx.mobileledger.json.Parse
     public static ParsedLedgerTransaction fromLedgerTransaction(LedgerTransaction tr) {
         ParsedLedgerTransaction
                 result = new ParsedLedgerTransaction();
-        result.setTcomment(tr.getComment());
+        result.setTcomment(Misc.nullIsEmpty(tr.getComment()));
         result.setTprecedingcomment("");
 
         ArrayList<ParsedPosting> postings = new ArrayList<>();
index 798cb81c8bff5d03ff4b9b489bee2e236f2f90bb..74b02a5090de22278eaafaec1dc70b42e1ad9974 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2019 Damyan Ivanov.
+ * Copyright © 2020 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
@@ -20,12 +20,8 @@ package net.ktnx.mobileledger.json.v1_14;
 import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
 
 @JsonIgnoreProperties(ignoreUnknown = true)
-public class ParsedStyle {
+public class ParsedStyle extends net.ktnx.mobileledger.json.ParsedStyle {
     private int asprecision;
-    private char asdecimalpoint;
-    private char ascommodityside;
-    private int digitgroups;
-    private boolean ascommodityspaced;
     public ParsedStyle() {
     }
     public int getAsprecision() {
@@ -34,28 +30,4 @@ public class ParsedStyle {
     public void setAsprecision(int asprecision) {
         this.asprecision = asprecision;
     }
-    public char getAsdecimalpoint() {
-        return asdecimalpoint;
-    }
-    public void setAsdecimalpoint(char asdecimalpoint) {
-        this.asdecimalpoint = asdecimalpoint;
-    }
-    public char getAscommodityside() {
-        return ascommodityside;
-    }
-    public void setAscommodityside(char ascommodityside) {
-        this.ascommodityside = ascommodityside;
-    }
-    public int getDigitgroups() {
-        return digitgroups;
-    }
-    public void setDigitgroups(int digitgroups) {
-        this.digitgroups = digitgroups;
-    }
-    public boolean isAscommodityspaced() {
-        return ascommodityspaced;
-    }
-    public void setAscommodityspaced(boolean ascommodityspaced) {
-        this.ascommodityspaced = ascommodityspaced;
-    }
 }
index 742c595757103ce34cb94ea1458a8cac09cee47e..f9be5a3ae4fda936ee40675b424736fe79180cbf 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2019 Damyan Ivanov.
+ * Copyright © 2020 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
@@ -21,10 +21,13 @@ import com.fasterxml.jackson.databind.MappingIterator;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.ObjectReader;
 
+import net.ktnx.mobileledger.model.LedgerTransaction;
+
 import java.io.IOException;
 import java.io.InputStream;
+import java.text.ParseException;
 
-public class TransactionListParser {
+public class TransactionListParser extends net.ktnx.mobileledger.json.TransactionListParser {
 
     private final MappingIterator<ParsedLedgerTransaction> iterator;
 
@@ -34,7 +37,8 @@ public class TransactionListParser {
         ObjectReader reader = mapper.readerFor(ParsedLedgerTransaction.class);
         iterator = reader.readValues(input);
     }
-    public ParsedLedgerTransaction nextTransaction() {
-        return iterator.hasNext() ? iterator.next() : null;
+    public LedgerTransaction nextTransaction() throws ParseException {
+        return iterator.hasNext() ? iterator.next()
+                                            .asLedgerTransaction() : null;
     }
 }
index d999b4571768912b365078a7539e8b270988ce20..f8f4dbb3d417669d17ba611daea6930a0648dfa6 100644 (file)
 
 package net.ktnx.mobileledger.json.v1_15;
 
-import com.fasterxml.jackson.databind.MappingIterator;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.ObjectReader;
 
+import net.ktnx.mobileledger.json.API;
+
 import java.io.IOException;
 import java.io.InputStream;
 
-import static net.ktnx.mobileledger.utils.Logger.debug;
-
-public class AccountListParser {
-
-    private final MappingIterator<ParsedLedgerAccount> iterator;
+public class AccountListParser extends net.ktnx.mobileledger.json.AccountListParser {
 
     public AccountListParser(InputStream input) throws IOException {
         ObjectMapper mapper = new ObjectMapper();
@@ -36,14 +33,8 @@ public class AccountListParser {
 
         iterator = reader.readValues(input);
     }
-    public ParsedLedgerAccount nextAccount() {
-        if (!iterator.hasNext()) return null;
-
-        ParsedLedgerAccount next = iterator.next();
-
-        if (next.getAname().equalsIgnoreCase("root")) return nextAccount();
-
-        debug("accounts", String.format("Got account '%s' [v1.15]", next.getAname()));
-        return next;
+    @Override
+    public API getApiVersion() {
+        return API.v1_15;
     }
 }
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/v1_15/Gateway.java b/app/src/main/java/net/ktnx/mobileledger/json/v1_15/Gateway.java
new file mode 100644 (file)
index 0000000..a4a1268
--- /dev/null
@@ -0,0 +1,38 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json.v1_15;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.ObjectWriter;
+
+import net.ktnx.mobileledger.model.LedgerTransaction;
+
+public class Gateway extends net.ktnx.mobileledger.json.Gateway {
+    @Override
+    public String transactionSaveRequest(LedgerTransaction ledgerTransaction)
+            throws JsonProcessingException {
+        net.ktnx.mobileledger.json.v1_15.ParsedLedgerTransaction jsonTransaction =
+                net.ktnx.mobileledger.json.v1_15.ParsedLedgerTransaction.fromLedgerTransaction(
+                        ledgerTransaction);
+        ObjectMapper mapper = new ObjectMapper();
+        ObjectWriter writer =
+                mapper.writerFor(net.ktnx.mobileledger.json.v1_15.ParsedLedgerTransaction.class);
+        return writer.writeValueAsString(jsonTransaction);
+    }
+}
index 24e9ccb07f8015523c287e4860d00d359dff5d0c..371c12210411d9d2d5e7e6ca0e0f8c2902c5b217 100644 (file)
 
 package net.ktnx.mobileledger.json.v1_15;
 
-import androidx.annotation.NonNull;
-
 import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
 
 @JsonIgnoreProperties(ignoreUnknown = true)
-public class ParsedBalance {
-    private ParsedQuantity aquantity;
-    private String acommodity;
+public class ParsedBalance extends net.ktnx.mobileledger.json.ParsedBalance {
     private ParsedStyle astyle;
     public ParsedBalance() {
     }
-    public ParsedQuantity getAquantity() {
-        return aquantity;
-    }
-    public void setAquantity(ParsedQuantity aquantity) {
-        this.aquantity = aquantity;
-    }
-    @NonNull
-    public String getAcommodity() {
-        return (acommodity == null) ? "" : acommodity;
-    }
-    public void setAcommodity(String acommodity) {
-        this.acommodity = acommodity;
-    }
     public ParsedStyle getAstyle() {
         return astyle;
     }
index 5ec1464b1d80dfe0030940199cb5a4cb813ebd76..8e762cabe8a5060969d1f12625bbd8420da0a36b 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2019 Damyan Ivanov.
+ * Copyright © 2020 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
@@ -19,39 +19,5 @@ package net.ktnx.mobileledger.json.v1_15;
 
 import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
 
-import java.util.List;
-
 @JsonIgnoreProperties(ignoreUnknown = true)
-public class ParsedLedgerAccount {
-    private List<ParsedBalance> aebalance;
-    private List<ParsedBalance> aibalance;
-    private String aname;
-    private int anumpostings;
-    public ParsedLedgerAccount() {
-    }
-    public int getAnumpostings() {
-        return anumpostings;
-    }
-    public void setAnumpostings(int anumpostings) {
-        this.anumpostings = anumpostings;
-    }
-    public List<ParsedBalance> getAebalance() {
-        return aebalance;
-    }
-    public void setAebalance(List<ParsedBalance> aebalance) {
-        this.aebalance = aebalance;
-    }
-    public List<ParsedBalance> getAibalance() {
-        return aibalance;
-    }
-    public void setAibalance(List<ParsedBalance> aibalance) {
-        this.aibalance = aibalance;
-    }
-    public String getAname() {
-        return aname;
-    }
-    public void setAname(String aname) {
-        this.aname = aname;
-    }
-
-}
+public class ParsedLedgerAccount extends net.ktnx.mobileledger.json.v1_14.ParsedLedgerAccount {}
index 9a9bfdc0bf4fd79760e4e2155a698a2420c3431f..bc1950badca0ff381f701c0cb511fe3d36da7da4 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2020 Damyan Ivanov.
+ * Copyright © 2021 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
@@ -46,7 +46,7 @@ public class ParsedLedgerTransaction implements net.ktnx.mobileledger.json.Parse
     }
     public static ParsedLedgerTransaction fromLedgerTransaction(LedgerTransaction tr) {
         ParsedLedgerTransaction result = new ParsedLedgerTransaction();
-        result.setTcomment(tr.getComment());
+        result.setTcomment(Misc.nullIsEmpty(tr.getComment()));
         result.setTprecedingcomment("");
 
         ArrayList<ParsedPosting> postings = new ArrayList<>();
index f40968307301df8aa41ee7652bc4003d055a2aa7..9a81fcc7170ea08bf5d61c35d84432d8a5fc6702 100644 (file)
@@ -20,39 +20,5 @@ package net.ktnx.mobileledger.json.v1_15;
 import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
 
 @JsonIgnoreProperties(ignoreUnknown = true)
-public class ParsedQuantity {
-    private long decimalMantissa;
-    private int decimalPlaces;
-    public ParsedQuantity() {
-    }
-    public ParsedQuantity(String input) {
-        parseString(input);
-    }
-    public long getDecimalMantissa() {
-        return decimalMantissa;
-    }
-    public void setDecimalMantissa(long decimalMantissa) {
-        this.decimalMantissa = decimalMantissa;
-    }
-    public int getDecimalPlaces() {
-        return decimalPlaces;
-    }
-    public void setDecimalPlaces(int decimalPlaces) {
-        this.decimalPlaces = decimalPlaces;
-    }
-    public float asFloat() {
-        return (float) (decimalMantissa * Math.pow(10, -decimalPlaces));
-    }
-    public void parseString(String input) {
-        int pointPos = input.indexOf('.');
-        if (pointPos >= 0) {
-            String integral = input.replace(".", "");
-            decimalMantissa = Long.parseLong(integral);
-            decimalPlaces = input.length() - pointPos - 1;
-        }
-        else {
-            decimalMantissa = Long.parseLong(input);
-            decimalPlaces = 0;
-        }
-    }
+public class ParsedQuantity extends net.ktnx.mobileledger.json.ParsedQuantity {
 }
index 554133c5105c72f18fcd9abe89e9218a59cbfa0d..b2f0f742acfb4ff394fede8ae7ad6003e83ca14a 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2019 Damyan Ivanov.
+ * Copyright © 2020 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
@@ -20,42 +20,4 @@ package net.ktnx.mobileledger.json.v1_15;
 import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
 
 @JsonIgnoreProperties(ignoreUnknown = true)
-public class ParsedStyle {
-    private int asprecision;
-    private char asdecimalpoint;
-    private char ascommodityside;
-    private int digitgroups;
-    private boolean ascommodityspaced;
-    public ParsedStyle() {
-    }
-    public int getAsprecision() {
-        return asprecision;
-    }
-    public void setAsprecision(int asprecision) {
-        this.asprecision = asprecision;
-    }
-    public char getAsdecimalpoint() {
-        return asdecimalpoint;
-    }
-    public void setAsdecimalpoint(char asdecimalpoint) {
-        this.asdecimalpoint = asdecimalpoint;
-    }
-    public char getAscommodityside() {
-        return ascommodityside;
-    }
-    public void setAscommodityside(char ascommodityside) {
-        this.ascommodityside = ascommodityside;
-    }
-    public int getDigitgroups() {
-        return digitgroups;
-    }
-    public void setDigitgroups(int digitgroups) {
-        this.digitgroups = digitgroups;
-    }
-    public boolean isAscommodityspaced() {
-        return ascommodityspaced;
-    }
-    public void setAscommodityspaced(boolean ascommodityspaced) {
-        this.ascommodityspaced = ascommodityspaced;
-    }
-}
+public class ParsedStyle extends net.ktnx.mobileledger.json.v1_14.ParsedStyle {}
index 940deee4ff02de44385187524c74455fffd3c6fb..aab1bc41de499197e3d10f227583672825a78189 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2019 Damyan Ivanov.
+ * Copyright © 2020 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
@@ -21,10 +21,13 @@ import com.fasterxml.jackson.databind.MappingIterator;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.ObjectReader;
 
+import net.ktnx.mobileledger.model.LedgerTransaction;
+
 import java.io.IOException;
 import java.io.InputStream;
+import java.text.ParseException;
 
-public class TransactionListParser {
+public class TransactionListParser extends net.ktnx.mobileledger.json.TransactionListParser {
 
     private final MappingIterator<ParsedLedgerTransaction> iterator;
 
@@ -34,7 +37,8 @@ public class TransactionListParser {
         ObjectReader reader = mapper.readerFor(ParsedLedgerTransaction.class);
         iterator = reader.readValues(input);
     }
-    public ParsedLedgerTransaction nextTransaction() {
-        return iterator.hasNext() ? iterator.next() : null;
+    public LedgerTransaction nextTransaction() throws ParseException {
+        return iterator.hasNext() ? iterator.next()
+                                            .asLedgerTransaction() : null;
     }
 }
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/AccountListParser.java b/app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/AccountListParser.java
new file mode 100644 (file)
index 0000000..85a64c8
--- /dev/null
@@ -0,0 +1,40 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json.v1_19_1;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.ObjectReader;
+
+import net.ktnx.mobileledger.json.API;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+public class AccountListParser extends net.ktnx.mobileledger.json.AccountListParser {
+
+    public AccountListParser(InputStream input) throws IOException {
+        ObjectMapper mapper = new ObjectMapper();
+        ObjectReader reader = mapper.readerFor(ParsedLedgerAccount.class);
+
+        iterator = reader.readValues(input);
+    }
+    @Override
+    public API getApiVersion() {
+        return API.v1_19_1;
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/Gateway.java b/app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/Gateway.java
new file mode 100644 (file)
index 0000000..239ec2d
--- /dev/null
@@ -0,0 +1,38 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json.v1_19_1;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.ObjectWriter;
+
+import net.ktnx.mobileledger.model.LedgerTransaction;
+
+public class Gateway extends net.ktnx.mobileledger.json.Gateway {
+    @Override
+    public String transactionSaveRequest(LedgerTransaction ledgerTransaction)
+            throws JsonProcessingException {
+        net.ktnx.mobileledger.json.v1_19_1.ParsedLedgerTransaction jsonTransaction =
+                net.ktnx.mobileledger.json.v1_19_1.ParsedLedgerTransaction.fromLedgerTransaction(
+                        ledgerTransaction);
+        ObjectMapper mapper = new ObjectMapper();
+        ObjectWriter writer =
+                mapper.writerFor(net.ktnx.mobileledger.json.v1_19_1.ParsedLedgerTransaction.class);
+        return writer.writeValueAsString(jsonTransaction);
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/ParsedAmount.java b/app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/ParsedAmount.java
new file mode 100644 (file)
index 0000000..12cdbac
--- /dev/null
@@ -0,0 +1,62 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json.v1_19_1;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class ParsedAmount {
+    private String acommodity;
+    private ParsedQuantity aquantity;
+    private boolean aismultiplier;
+    private ParsedStyle astyle;
+    private ParsedPrice aprice;
+    public ParsedAmount() {
+    }
+    public ParsedPrice getAprice() {
+        return aprice;
+    }
+    public void setAprice(ParsedPrice aprice) {
+        this.aprice = aprice;
+    }
+    public String getAcommodity() {
+        return acommodity;
+    }
+    public void setAcommodity(String acommodity) {
+        this.acommodity = acommodity;
+    }
+    public ParsedQuantity getAquantity() {
+        return aquantity;
+    }
+    public void setAquantity(ParsedQuantity aquantity) {
+        this.aquantity = aquantity;
+    }
+    public boolean isAismultiplier() {
+        return aismultiplier;
+    }
+    public void setAismultiplier(boolean aismultiplier) {
+        this.aismultiplier = aismultiplier;
+    }
+    public ParsedStyle getAstyle() {
+        return astyle;
+    }
+    public void setAstyle(ParsedStyle astyle) {
+        this.astyle = astyle;
+    }
+
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/ParsedBalance.java b/app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/ParsedBalance.java
new file mode 100644 (file)
index 0000000..5defe08
--- /dev/null
@@ -0,0 +1,33 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json.v1_19_1;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class ParsedBalance extends net.ktnx.mobileledger.json.ParsedBalance {
+    private ParsedStyle astyle;
+    public ParsedBalance() {
+    }
+    public ParsedStyle getAstyle() {
+        return astyle;
+    }
+    public void setAstyle(ParsedStyle astyle) {
+        this.astyle = astyle;
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/ParsedLedgerAccount.java b/app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/ParsedLedgerAccount.java
new file mode 100644 (file)
index 0000000..5fabdb5
--- /dev/null
@@ -0,0 +1,53 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json.v1_19_1;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class ParsedLedgerAccount extends net.ktnx.mobileledger.json.ParsedLedgerAccount {
+    private List<ParsedBalance> aebalance;
+    private List<ParsedBalance> aibalance;
+    public ParsedLedgerAccount() {
+    }
+    public List<ParsedBalance> getAibalance() {
+        return aibalance;
+    }
+    public void setAibalance(List<ParsedBalance> aibalance) {
+        this.aibalance = aibalance;
+    }
+    public List<ParsedBalance> getAebalance() {
+        return aebalance;
+    }
+    public void setAebalance(List<ParsedBalance> aebalance) {
+        this.aebalance = aebalance;
+    }
+    @Override
+    public List<SimpleBalance> getSimpleBalance() {
+        List<SimpleBalance> result = new ArrayList<SimpleBalance>();
+        for (ParsedBalance b : getAibalance()) {
+            result.add(new SimpleBalance(b.getAcommodity(), b.getAquantity()
+                                                             .asFloat()));
+        }
+
+        return result;
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/ParsedLedgerTransaction.java b/app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/ParsedLedgerTransaction.java
new file mode 100644 (file)
index 0000000..196408c
--- /dev/null
@@ -0,0 +1,160 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json.v1_19_1;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+import net.ktnx.mobileledger.model.LedgerTransaction;
+import net.ktnx.mobileledger.model.LedgerTransactionAccount;
+import net.ktnx.mobileledger.utils.Globals;
+import net.ktnx.mobileledger.utils.Misc;
+import net.ktnx.mobileledger.utils.SimpleDate;
+
+import java.text.ParseException;
+import java.util.ArrayList;
+import java.util.List;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class ParsedLedgerTransaction implements net.ktnx.mobileledger.json.ParsedLedgerTransaction {
+    private String tdate;
+    private String tdate2 = null;
+    private String tdescription;
+    private String tcomment;
+    private String tcode = "";
+    private String tstatus = "Unmarked";
+    private String tprecedingcomment = "";
+    private int tindex;
+    private List<ParsedPosting> tpostings;
+    private List<List<String>> ttags = new ArrayList<>();
+    private ParsedSourcePos tsourcepos = new ParsedSourcePos();
+    public ParsedLedgerTransaction() {
+    }
+    public static ParsedLedgerTransaction fromLedgerTransaction(LedgerTransaction tr) {
+        ParsedLedgerTransaction result = new ParsedLedgerTransaction();
+        result.setTcomment(Misc.nullIsEmpty(tr.getComment()));
+        result.setTprecedingcomment("");
+
+        ArrayList<ParsedPosting> postings = new ArrayList<>();
+        for (LedgerTransactionAccount acc : tr.getAccounts()) {
+            if (!acc.getAccountName()
+                    .isEmpty())
+                postings.add(ParsedPosting.fromLedgerAccount(acc));
+        }
+
+        result.setTpostings(postings);
+        SimpleDate transactionDate = tr.getDateIfAny();
+        if (transactionDate == null) {
+            transactionDate = SimpleDate.today();
+        }
+        result.setTdate(Globals.formatIsoDate(transactionDate));
+        result.setTdate2(null);
+        result.setTindex(1);
+        result.setTdescription(tr.getDescription());
+        return result;
+    }
+    public String getTcode() {
+        return tcode;
+    }
+    public void setTcode(String tcode) {
+        this.tcode = tcode;
+    }
+    public String getTstatus() {
+        return tstatus;
+    }
+    public void setTstatus(String tstatus) {
+        this.tstatus = tstatus;
+    }
+    public List<List<String>> getTtags() {
+        return ttags;
+    }
+    public void setTtags(List<List<String>> ttags) {
+        this.ttags = ttags;
+    }
+    public ParsedSourcePos getTsourcepos() {
+        return tsourcepos;
+    }
+    public void setTsourcepos(ParsedSourcePos tsourcepos) {
+        this.tsourcepos = tsourcepos;
+    }
+    public String getTprecedingcomment() {
+        return tprecedingcomment;
+    }
+    public void setTprecedingcomment(String tprecedingcomment) {
+        this.tprecedingcomment = tprecedingcomment;
+    }
+    public String getTdate() {
+        return tdate;
+    }
+    public void setTdate(String tdate) {
+        this.tdate = tdate;
+    }
+    public String getTdate2() {
+        return tdate2;
+    }
+    public void setTdate2(String tdate2) {
+        this.tdate2 = tdate2;
+    }
+    public String getTdescription() {
+        return tdescription;
+    }
+    public void setTdescription(String tdescription) {
+        this.tdescription = tdescription;
+    }
+    public String getTcomment() {
+        return tcomment;
+    }
+    public void setTcomment(String tcomment) {
+        this.tcomment = tcomment;
+    }
+    public int getTindex() {
+        return tindex;
+    }
+    public void setTindex(int tindex) {
+        this.tindex = tindex;
+        if (tpostings != null)
+            for (ParsedPosting p : tpostings) {
+                p.setPtransaction_(tindex);
+            }
+    }
+    public List<ParsedPosting> getTpostings() {
+        return tpostings;
+    }
+    public void setTpostings(List<ParsedPosting> tpostings) {
+        this.tpostings = tpostings;
+    }
+    public void addPosting(ParsedPosting posting) {
+        posting.setPtransaction_(tindex);
+        tpostings.add(posting);
+    }
+    public LedgerTransaction asLedgerTransaction() throws ParseException {
+        SimpleDate date = Globals.parseIsoDate(tdate);
+        LedgerTransaction tr = new LedgerTransaction(tindex, date, tdescription);
+        tr.setComment(Misc.trim(Misc.emptyIsNull(tcomment)));
+
+        List<ParsedPosting> postings = tpostings;
+
+        if (postings != null) {
+            for (ParsedPosting p : postings) {
+                tr.addAccount(p.asLedgerAccount());
+            }
+        }
+
+        tr.markDataAsLoaded();
+        return tr;
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/ParsedPosting.java b/app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/ParsedPosting.java
new file mode 100644 (file)
index 0000000..e233062
--- /dev/null
@@ -0,0 +1,144 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json.v1_19_1;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+import net.ktnx.mobileledger.model.LedgerTransactionAccount;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class ParsedPosting extends net.ktnx.mobileledger.json.ParsedPosting {
+    private Void pbalanceassertion;
+    private String pstatus = "Unmarked";
+    private String paccount;
+    private List<ParsedAmount> pamount;
+    private String pdate = null;
+    private String pdate2 = null;
+    private String ptype = "RegularPosting";
+    private String pcomment = "";
+    private List<List<String>> ptags = new ArrayList<>();
+    private String poriginal = null;
+    private int ptransaction_;
+    public ParsedPosting() {
+    }
+    public static ParsedPosting fromLedgerAccount(LedgerTransactionAccount acc) {
+        ParsedPosting result = new ParsedPosting();
+        result.setPaccount(acc.getAccountName());
+
+        String comment = acc.getComment();
+        if (comment == null)
+            comment = "";
+        result.setPcomment(comment);
+
+        ArrayList<ParsedAmount> amounts = new ArrayList<>();
+        ParsedAmount amt = new ParsedAmount();
+        amt.setAcommodity((acc.getCurrency() == null) ? "" : acc.getCurrency());
+        amt.setAismultiplier(false);
+        ParsedQuantity qty = new ParsedQuantity();
+        qty.setDecimalPlaces(2);
+        qty.setDecimalMantissa(Math.round(acc.getAmount() * 100));
+        amt.setAquantity(qty);
+        ParsedStyle style = new ParsedStyle();
+        style.setAscommodityside(getCommoditySide());
+        style.setAscommodityspaced(getCommoditySpaced());
+        style.setAsprecision(new ParsedPrecision(2));
+        style.setAsdecimalpoint('.');
+        amt.setAstyle(style);
+        if (acc.getCurrency() != null)
+            amt.setAcommodity(acc.getCurrency());
+        amounts.add(amt);
+        result.setPamount(amounts);
+        return result;
+    }
+    public String getPdate2() {
+        return pdate2;
+    }
+    public void setPdate2(String pdate2) {
+        this.pdate2 = pdate2;
+    }
+    public int getPtransaction_() {
+        return ptransaction_;
+    }
+    public void setPtransaction_(int ptransaction_) {
+        this.ptransaction_ = ptransaction_;
+    }
+    public String getPdate() {
+        return pdate;
+    }
+    public void setPdate(String pdate) {
+        this.pdate = pdate;
+    }
+    public String getPtype() {
+        return ptype;
+    }
+    public void setPtype(String ptype) {
+        this.ptype = ptype;
+    }
+    public String getPcomment() {
+        return pcomment;
+    }
+    public void setPcomment(String pcomment) {
+        this.pcomment = (pcomment == null) ? null : pcomment.trim();
+    }
+    public List<List<String>> getPtags() {
+        return ptags;
+    }
+    public void setPtags(List<List<String>> ptags) {
+        this.ptags = ptags;
+    }
+    public String getPoriginal() {
+        return poriginal;
+    }
+    public void setPoriginal(String poriginal) {
+        this.poriginal = poriginal;
+    }
+    public String getPstatus() {
+        return pstatus;
+    }
+    public void setPstatus(String pstatus) {
+        this.pstatus = pstatus;
+    }
+    public Void getPbalanceassertion() {
+        return pbalanceassertion;
+    }
+    public void setPbalanceassertion(Void pbalanceassertion) {
+        this.pbalanceassertion = pbalanceassertion;
+    }
+    public String getPaccount() {
+        return paccount;
+    }
+    public void setPaccount(String paccount) {
+        this.paccount = paccount;
+    }
+    public List<ParsedAmount> getPamount() {
+        return pamount;
+    }
+    public void setPamount(List<ParsedAmount> pamount) {
+        this.pamount = pamount;
+    }
+    public LedgerTransactionAccount asLedgerAccount() {
+        ParsedAmount amt = pamount.get(0);
+        return new LedgerTransactionAccount(paccount, amt.getAquantity()
+                                                         .asFloat(), amt.getAcommodity(),
+                getPcomment());
+    }
+
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/ParsedPrecision.java b/app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/ParsedPrecision.java
new file mode 100644 (file)
index 0000000..4472cf4
--- /dev/null
@@ -0,0 +1,45 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json.v1_19_1;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+class ParsedPrecision {
+    private int contents;
+    private String tag;
+    ParsedPrecision() {
+        tag = "NaturalPrecision";
+    }
+    ParsedPrecision(int contents) {
+        this.contents = contents;
+        tag = "Precision";
+    }
+    public int getContents() {
+        return contents;
+    }
+    public void setContents(int contents) {
+        this.contents = contents;
+    }
+    public String getTag() {
+        return tag;
+    }
+    public void setTag(String tag) {
+        this.tag = tag;
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/ParsedPrice.java b/app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/ParsedPrice.java
new file mode 100644 (file)
index 0000000..0e1ea54
--- /dev/null
@@ -0,0 +1,78 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json.v1_19_1;
+
+class ParsedPrice {
+    private String tag;
+    private Contents contents;
+    public ParsedPrice() {
+        tag = "NoPrice";
+    }
+    public Contents getContents() {
+        return contents;
+    }
+    public void setContents(Contents contents) {
+        this.contents = contents;
+    }
+    public String getTag() {
+        return tag;
+    }
+    public void setTag(String tag) {
+        this.tag = tag;
+    }
+    private static class Contents {
+        private ParsedPrice aprice;
+        private ParsedQuantity aquantity;
+        private String acommodity;
+        private boolean aismultiplier;
+        private ParsedStyle astyle;
+        public Contents() {
+            acommodity = "";
+        }
+        public ParsedPrice getAprice() {
+            return aprice;
+        }
+        public void setAprice(ParsedPrice aprice) {
+            this.aprice = aprice;
+        }
+        public ParsedQuantity getAquantity() {
+            return aquantity;
+        }
+        public void setAquantity(ParsedQuantity aquantity) {
+            this.aquantity = aquantity;
+        }
+        public String getAcommodity() {
+            return acommodity;
+        }
+        public void setAcommodity(String acommodity) {
+            this.acommodity = acommodity;
+        }
+        public boolean isAismultiplier() {
+            return aismultiplier;
+        }
+        public void setAismultiplier(boolean aismultiplier) {
+            this.aismultiplier = aismultiplier;
+        }
+        public ParsedStyle getAstyle() {
+            return astyle;
+        }
+        public void setAstyle(ParsedStyle astyle) {
+            this.astyle = astyle;
+        }
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/ParsedQuantity.java b/app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/ParsedQuantity.java
new file mode 100644 (file)
index 0000000..951bb56
--- /dev/null
@@ -0,0 +1,23 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json.v1_19_1;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class ParsedQuantity extends net.ktnx.mobileledger.json.ParsedQuantity {}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/ParsedSourcePos.java b/app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/ParsedSourcePos.java
new file mode 100644 (file)
index 0000000..650f97c
--- /dev/null
@@ -0,0 +1,43 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json.v1_19_1;
+
+import java.util.ArrayList;
+import java.util.List;
+
+class ParsedSourcePos {
+    private String tag = "JournalSourcePos";
+    private List<Object> contents;
+    public ParsedSourcePos() {
+        contents = new ArrayList<>();
+        contents.add("");
+        contents.add(new Integer[]{1, 1});
+    }
+    public String getTag() {
+        return tag;
+    }
+    public void setTag(String tag) {
+        this.tag = tag;
+    }
+    public List<Object> getContents() {
+        return contents;
+    }
+    public void setContents(List<Object> contents) {
+        this.contents = contents;
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/ParsedStyle.java b/app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/ParsedStyle.java
new file mode 100644 (file)
index 0000000..b0398a1
--- /dev/null
@@ -0,0 +1,33 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json.v1_19_1;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class ParsedStyle extends net.ktnx.mobileledger.json.ParsedStyle {
+    private ParsedPrecision asprecision;
+    public ParsedStyle() {
+    }
+    public ParsedPrecision getAsprecision() {
+        return asprecision;
+    }
+    public void setAsprecision(ParsedPrecision asprecision) {
+        this.asprecision = asprecision;
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/TransactionListParser.java b/app/src/main/java/net/ktnx/mobileledger/json/v1_19_1/TransactionListParser.java
new file mode 100644 (file)
index 0000000..12564a2
--- /dev/null
@@ -0,0 +1,44 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json.v1_19_1;
+
+import com.fasterxml.jackson.databind.MappingIterator;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.ObjectReader;
+
+import net.ktnx.mobileledger.model.LedgerTransaction;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.text.ParseException;
+
+public class TransactionListParser extends net.ktnx.mobileledger.json.TransactionListParser {
+
+    private final MappingIterator<ParsedLedgerTransaction> iterator;
+
+    public TransactionListParser(InputStream input) throws IOException {
+
+        ObjectMapper mapper = new ObjectMapper();
+        ObjectReader reader = mapper.readerFor(ParsedLedgerTransaction.class);
+        iterator = reader.readValues(input);
+    }
+    public LedgerTransaction nextTransaction() throws ParseException {
+        return iterator.hasNext() ? iterator.next()
+                                            .asLedgerTransaction() : null;
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/v1_23/AccountListParser.java b/app/src/main/java/net/ktnx/mobileledger/json/v1_23/AccountListParser.java
new file mode 100644 (file)
index 0000000..904fb57
--- /dev/null
@@ -0,0 +1,40 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json.v1_23;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.ObjectReader;
+
+import net.ktnx.mobileledger.json.API;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+public class AccountListParser extends net.ktnx.mobileledger.json.AccountListParser {
+
+    public AccountListParser(InputStream input) throws IOException {
+        ObjectMapper mapper = new ObjectMapper();
+        ObjectReader reader = mapper.readerFor(ParsedLedgerAccount.class);
+
+        iterator = reader.readValues(input);
+    }
+    @Override
+    public API getApiVersion() {
+        return API.v1_19_1;
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/v1_23/Gateway.java b/app/src/main/java/net/ktnx/mobileledger/json/v1_23/Gateway.java
new file mode 100644 (file)
index 0000000..c04f6f2
--- /dev/null
@@ -0,0 +1,36 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json.v1_23;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.ObjectWriter;
+
+import net.ktnx.mobileledger.model.LedgerTransaction;
+
+public class Gateway extends net.ktnx.mobileledger.json.Gateway {
+    @Override
+    public String transactionSaveRequest(LedgerTransaction ledgerTransaction)
+            throws JsonProcessingException {
+        ParsedLedgerTransaction jsonTransaction =
+                ParsedLedgerTransaction.fromLedgerTransaction(ledgerTransaction);
+        ObjectMapper mapper = new ObjectMapper();
+        ObjectWriter writer = mapper.writerFor(ParsedLedgerTransaction.class);
+        return writer.writeValueAsString(jsonTransaction);
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/v1_23/ParsedAmount.java b/app/src/main/java/net/ktnx/mobileledger/json/v1_23/ParsedAmount.java
new file mode 100644 (file)
index 0000000..58f88c1
--- /dev/null
@@ -0,0 +1,62 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json.v1_23;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class ParsedAmount {
+    private String acommodity;
+    private ParsedQuantity aquantity;
+    private boolean aismultiplier;
+    private ParsedStyle astyle;
+    private ParsedPrice aprice;
+    public ParsedAmount() {
+    }
+    public ParsedPrice getAprice() {
+        return aprice;
+    }
+    public void setAprice(ParsedPrice aprice) {
+        this.aprice = aprice;
+    }
+    public String getAcommodity() {
+        return acommodity;
+    }
+    public void setAcommodity(String acommodity) {
+        this.acommodity = acommodity;
+    }
+    public ParsedQuantity getAquantity() {
+        return aquantity;
+    }
+    public void setAquantity(ParsedQuantity aquantity) {
+        this.aquantity = aquantity;
+    }
+    public boolean isAismultiplier() {
+        return aismultiplier;
+    }
+    public void setAismultiplier(boolean aismultiplier) {
+        this.aismultiplier = aismultiplier;
+    }
+    public ParsedStyle getAstyle() {
+        return astyle;
+    }
+    public void setAstyle(ParsedStyle astyle) {
+        this.astyle = astyle;
+    }
+
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/v1_23/ParsedBalance.java b/app/src/main/java/net/ktnx/mobileledger/json/v1_23/ParsedBalance.java
new file mode 100644 (file)
index 0000000..4f74de4
--- /dev/null
@@ -0,0 +1,33 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json.v1_23;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class ParsedBalance extends net.ktnx.mobileledger.json.ParsedBalance {
+    private ParsedStyle astyle;
+    public ParsedBalance() {
+    }
+    public ParsedStyle getAstyle() {
+        return astyle;
+    }
+    public void setAstyle(ParsedStyle astyle) {
+        this.astyle = astyle;
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/v1_23/ParsedLedgerAccount.java b/app/src/main/java/net/ktnx/mobileledger/json/v1_23/ParsedLedgerAccount.java
new file mode 100644 (file)
index 0000000..6d942e8
--- /dev/null
@@ -0,0 +1,53 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json.v1_23;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class ParsedLedgerAccount extends net.ktnx.mobileledger.json.ParsedLedgerAccount {
+    private List<ParsedBalance> aebalance;
+    private List<ParsedBalance> aibalance;
+    public ParsedLedgerAccount() {
+    }
+    public List<ParsedBalance> getAibalance() {
+        return aibalance;
+    }
+    public void setAibalance(List<ParsedBalance> aibalance) {
+        this.aibalance = aibalance;
+    }
+    public List<ParsedBalance> getAebalance() {
+        return aebalance;
+    }
+    public void setAebalance(List<ParsedBalance> aebalance) {
+        this.aebalance = aebalance;
+    }
+    @Override
+    public List<SimpleBalance> getSimpleBalance() {
+        List<SimpleBalance> result = new ArrayList<SimpleBalance>();
+        for (ParsedBalance b : getAibalance()) {
+            result.add(new SimpleBalance(b.getAcommodity(), b.getAquantity()
+                                                             .asFloat()));
+        }
+
+        return result;
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/v1_23/ParsedLedgerTransaction.java b/app/src/main/java/net/ktnx/mobileledger/json/v1_23/ParsedLedgerTransaction.java
new file mode 100644 (file)
index 0000000..59d1763
--- /dev/null
@@ -0,0 +1,166 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json.v1_23;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+import net.ktnx.mobileledger.model.LedgerTransaction;
+import net.ktnx.mobileledger.model.LedgerTransactionAccount;
+import net.ktnx.mobileledger.utils.Globals;
+import net.ktnx.mobileledger.utils.Misc;
+import net.ktnx.mobileledger.utils.SimpleDate;
+
+import java.text.ParseException;
+import java.util.ArrayList;
+import java.util.List;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class ParsedLedgerTransaction implements net.ktnx.mobileledger.json.ParsedLedgerTransaction {
+    private String tdate;
+    private String tdate2 = null;
+    private String tdescription;
+    private String tcomment;
+    private String tcode = "";
+    private String tstatus = "Unmarked";
+    private String tprecedingcomment = "";
+    private int tindex;
+    private List<ParsedPosting> tpostings;
+    private List<List<String>> ttags = new ArrayList<>();
+    private List<ParsedSourcePos> tsourcepos = new ArrayList<>();
+    public ParsedLedgerTransaction() {
+        ParsedSourcePos startPos = new ParsedSourcePos();
+        ParsedSourcePos endPos = new ParsedSourcePos();
+        endPos.setSourceLine(2);
+
+        tsourcepos.add(startPos);
+        tsourcepos.add(endPos);
+    }
+    public static ParsedLedgerTransaction fromLedgerTransaction(LedgerTransaction tr) {
+        ParsedLedgerTransaction result = new ParsedLedgerTransaction();
+        result.setTcomment(Misc.nullIsEmpty(tr.getComment()));
+        result.setTprecedingcomment("");
+
+        ArrayList<ParsedPosting> postings = new ArrayList<>();
+        for (LedgerTransactionAccount acc : tr.getAccounts()) {
+            if (!acc.getAccountName()
+                    .isEmpty())
+                postings.add(ParsedPosting.fromLedgerAccount(acc));
+        }
+
+        result.setTpostings(postings);
+        SimpleDate transactionDate = tr.getDateIfAny();
+        if (transactionDate == null) {
+            transactionDate = SimpleDate.today();
+        }
+        result.setTdate(Globals.formatIsoDate(transactionDate));
+        result.setTdate2(null);
+        result.setTindex(1);
+        result.setTdescription(tr.getDescription());
+        return result;
+    }
+    public String getTcode() {
+        return tcode;
+    }
+    public void setTcode(String tcode) {
+        this.tcode = tcode;
+    }
+    public String getTstatus() {
+        return tstatus;
+    }
+    public void setTstatus(String tstatus) {
+        this.tstatus = tstatus;
+    }
+    public List<List<String>> getTtags() {
+        return ttags;
+    }
+    public void setTtags(List<List<String>> ttags) {
+        this.ttags = ttags;
+    }
+    public List<ParsedSourcePos> getTsourcepos() {
+        return tsourcepos;
+    }
+    public void setTsourcepos(List<ParsedSourcePos> tsourcepos) {
+        this.tsourcepos = tsourcepos;
+    }
+    public String getTprecedingcomment() {
+        return tprecedingcomment;
+    }
+    public void setTprecedingcomment(String tprecedingcomment) {
+        this.tprecedingcomment = tprecedingcomment;
+    }
+    public String getTdate() {
+        return tdate;
+    }
+    public void setTdate(String tdate) {
+        this.tdate = tdate;
+    }
+    public String getTdate2() {
+        return tdate2;
+    }
+    public void setTdate2(String tdate2) {
+        this.tdate2 = tdate2;
+    }
+    public String getTdescription() {
+        return tdescription;
+    }
+    public void setTdescription(String tdescription) {
+        this.tdescription = tdescription;
+    }
+    public String getTcomment() {
+        return tcomment;
+    }
+    public void setTcomment(String tcomment) {
+        this.tcomment = tcomment;
+    }
+    public int getTindex() {
+        return tindex;
+    }
+    public void setTindex(int tindex) {
+        this.tindex = tindex;
+        if (tpostings != null)
+            for (ParsedPosting p : tpostings) {
+                p.setPtransaction_(tindex);
+            }
+    }
+    public List<ParsedPosting> getTpostings() {
+        return tpostings;
+    }
+    public void setTpostings(List<ParsedPosting> tpostings) {
+        this.tpostings = tpostings;
+    }
+    public void addPosting(ParsedPosting posting) {
+        posting.setPtransaction_(tindex);
+        tpostings.add(posting);
+    }
+    public LedgerTransaction asLedgerTransaction() throws ParseException {
+        SimpleDate date = Globals.parseIsoDate(tdate);
+        LedgerTransaction tr = new LedgerTransaction(tindex, date, tdescription);
+        tr.setComment(Misc.trim(Misc.emptyIsNull(tcomment)));
+
+        List<ParsedPosting> postings = tpostings;
+
+        if (postings != null) {
+            for (ParsedPosting p : postings) {
+                tr.addAccount(p.asLedgerAccount());
+            }
+        }
+
+        tr.markDataAsLoaded();
+        return tr;
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/v1_23/ParsedPosting.java b/app/src/main/java/net/ktnx/mobileledger/json/v1_23/ParsedPosting.java
new file mode 100644 (file)
index 0000000..e60bd19
--- /dev/null
@@ -0,0 +1,144 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json.v1_23;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+import net.ktnx.mobileledger.model.LedgerTransactionAccount;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class ParsedPosting extends net.ktnx.mobileledger.json.ParsedPosting {
+    private Void pbalanceassertion;
+    private String pstatus = "Unmarked";
+    private String paccount;
+    private List<ParsedAmount> pamount;
+    private String pdate = null;
+    private String pdate2 = null;
+    private String ptype = "RegularPosting";
+    private String pcomment = "";
+    private List<List<String>> ptags = new ArrayList<>();
+    private String poriginal = null;
+    private int ptransaction_;
+    public ParsedPosting() {
+    }
+    public static ParsedPosting fromLedgerAccount(LedgerTransactionAccount acc) {
+        ParsedPosting result = new ParsedPosting();
+        result.setPaccount(acc.getAccountName());
+
+        String comment = acc.getComment();
+        if (comment == null)
+            comment = "";
+        result.setPcomment(comment);
+
+        ArrayList<ParsedAmount> amounts = new ArrayList<>();
+        ParsedAmount amt = new ParsedAmount();
+        amt.setAcommodity((acc.getCurrency() == null) ? "" : acc.getCurrency());
+        amt.setAismultiplier(false);
+        ParsedQuantity qty = new ParsedQuantity();
+        qty.setDecimalPlaces(2);
+        qty.setDecimalMantissa(Math.round(acc.getAmount() * 100));
+        amt.setAquantity(qty);
+        ParsedStyle style = new ParsedStyle();
+        style.setAscommodityside(getCommoditySide());
+        style.setAscommodityspaced(getCommoditySpaced());
+        style.setAsprecision(2);
+        style.setAsdecimalpoint('.');
+        amt.setAstyle(style);
+        if (acc.getCurrency() != null)
+            amt.setAcommodity(acc.getCurrency());
+        amounts.add(amt);
+        result.setPamount(amounts);
+        return result;
+    }
+    public String getPdate2() {
+        return pdate2;
+    }
+    public void setPdate2(String pdate2) {
+        this.pdate2 = pdate2;
+    }
+    public int getPtransaction_() {
+        return ptransaction_;
+    }
+    public void setPtransaction_(int ptransaction_) {
+        this.ptransaction_ = ptransaction_;
+    }
+    public String getPdate() {
+        return pdate;
+    }
+    public void setPdate(String pdate) {
+        this.pdate = pdate;
+    }
+    public String getPtype() {
+        return ptype;
+    }
+    public void setPtype(String ptype) {
+        this.ptype = ptype;
+    }
+    public String getPcomment() {
+        return pcomment;
+    }
+    public void setPcomment(String pcomment) {
+        this.pcomment = (pcomment == null) ? null : pcomment.trim();
+    }
+    public List<List<String>> getPtags() {
+        return ptags;
+    }
+    public void setPtags(List<List<String>> ptags) {
+        this.ptags = ptags;
+    }
+    public String getPoriginal() {
+        return poriginal;
+    }
+    public void setPoriginal(String poriginal) {
+        this.poriginal = poriginal;
+    }
+    public String getPstatus() {
+        return pstatus;
+    }
+    public void setPstatus(String pstatus) {
+        this.pstatus = pstatus;
+    }
+    public Void getPbalanceassertion() {
+        return pbalanceassertion;
+    }
+    public void setPbalanceassertion(Void pbalanceassertion) {
+        this.pbalanceassertion = pbalanceassertion;
+    }
+    public String getPaccount() {
+        return paccount;
+    }
+    public void setPaccount(String paccount) {
+        this.paccount = paccount;
+    }
+    public List<ParsedAmount> getPamount() {
+        return pamount;
+    }
+    public void setPamount(List<ParsedAmount> pamount) {
+        this.pamount = pamount;
+    }
+    public LedgerTransactionAccount asLedgerAccount() {
+        ParsedAmount amt = pamount.get(0);
+        return new LedgerTransactionAccount(paccount, amt.getAquantity()
+                                                         .asFloat(), amt.getAcommodity(),
+                getPcomment());
+    }
+
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/v1_23/ParsedPrice.java b/app/src/main/java/net/ktnx/mobileledger/json/v1_23/ParsedPrice.java
new file mode 100644 (file)
index 0000000..80a810a
--- /dev/null
@@ -0,0 +1,78 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json.v1_23;
+
+class ParsedPrice {
+    private String tag;
+    private Contents contents;
+    public ParsedPrice() {
+        tag = "NoPrice";
+    }
+    public Contents getContents() {
+        return contents;
+    }
+    public void setContents(Contents contents) {
+        this.contents = contents;
+    }
+    public String getTag() {
+        return tag;
+    }
+    public void setTag(String tag) {
+        this.tag = tag;
+    }
+    private static class Contents {
+        private ParsedPrice aprice;
+        private ParsedQuantity aquantity;
+        private String acommodity;
+        private boolean aismultiplier;
+        private ParsedStyle astyle;
+        public Contents() {
+            acommodity = "";
+        }
+        public ParsedPrice getAprice() {
+            return aprice;
+        }
+        public void setAprice(ParsedPrice aprice) {
+            this.aprice = aprice;
+        }
+        public ParsedQuantity getAquantity() {
+            return aquantity;
+        }
+        public void setAquantity(ParsedQuantity aquantity) {
+            this.aquantity = aquantity;
+        }
+        public String getAcommodity() {
+            return acommodity;
+        }
+        public void setAcommodity(String acommodity) {
+            this.acommodity = acommodity;
+        }
+        public boolean isAismultiplier() {
+            return aismultiplier;
+        }
+        public void setAismultiplier(boolean aismultiplier) {
+            this.aismultiplier = aismultiplier;
+        }
+        public ParsedStyle getAstyle() {
+            return astyle;
+        }
+        public void setAstyle(ParsedStyle astyle) {
+            this.astyle = astyle;
+        }
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/v1_23/ParsedQuantity.java b/app/src/main/java/net/ktnx/mobileledger/json/v1_23/ParsedQuantity.java
new file mode 100644 (file)
index 0000000..5502693
--- /dev/null
@@ -0,0 +1,23 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json.v1_23;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class ParsedQuantity extends net.ktnx.mobileledger.json.ParsedQuantity {}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/v1_23/ParsedSourcePos.java b/app/src/main/java/net/ktnx/mobileledger/json/v1_23/ParsedSourcePos.java
new file mode 100644 (file)
index 0000000..b3ea5db
--- /dev/null
@@ -0,0 +1,44 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json.v1_23;
+
+class ParsedSourcePos {
+    private String sourceName = "";
+    private int sourceLine = 1;
+    private int sourceColumn = 1;
+    public ParsedSourcePos() {
+    }
+    public String getSourceName() {
+        return sourceName;
+    }
+    public void setSourceName(String sourceName) {
+        this.sourceName = sourceName;
+    }
+    public int getSourceLine() {
+        return sourceLine;
+    }
+    public void setSourceLine(int sourceLine) {
+        this.sourceLine = sourceLine;
+    }
+    public int getSourceColumn() {
+        return sourceColumn;
+    }
+    public void setSourceColumn(int sourceColumn) {
+        this.sourceColumn = sourceColumn;
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/v1_23/ParsedStyle.java b/app/src/main/java/net/ktnx/mobileledger/json/v1_23/ParsedStyle.java
new file mode 100644 (file)
index 0000000..d3a0a13
--- /dev/null
@@ -0,0 +1,33 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json.v1_23;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class ParsedStyle extends net.ktnx.mobileledger.json.ParsedStyle {
+    private int asprecision;
+    public ParsedStyle() {
+    }
+    public int getAsprecision() {
+        return asprecision;
+    }
+    public void setAsprecision(int asprecision) {
+        this.asprecision = asprecision;
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/json/v1_23/TransactionListParser.java b/app/src/main/java/net/ktnx/mobileledger/json/v1_23/TransactionListParser.java
new file mode 100644 (file)
index 0000000..7f5350a
--- /dev/null
@@ -0,0 +1,44 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.json.v1_23;
+
+import com.fasterxml.jackson.databind.MappingIterator;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.ObjectReader;
+
+import net.ktnx.mobileledger.model.LedgerTransaction;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.text.ParseException;
+
+public class TransactionListParser extends net.ktnx.mobileledger.json.TransactionListParser {
+
+    private final MappingIterator<ParsedLedgerTransaction> iterator;
+
+    public TransactionListParser(InputStream input) throws IOException {
+
+        ObjectMapper mapper = new ObjectMapper();
+        ObjectReader reader = mapper.readerFor(ParsedLedgerTransaction.class);
+        iterator = reader.readValues(input);
+    }
+    public LedgerTransaction nextTransaction() throws ParseException {
+        return iterator.hasNext() ? iterator.next()
+                                            .asLedgerTransaction() : null;
+    }
+}
index 5fd951715eeaf609cf46358f909b18c9f1a052f9..807e93d348b1e182d81090d94fc097b32cd4be8f 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2020 Damyan Ivanov.
+ * Copyright © 2024 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
 package net.ktnx.mobileledger.model;
 
 import androidx.annotation.NonNull;
+import androidx.lifecycle.LiveData;
 
 import org.jetbrains.annotations.NotNull;
 
-public class AccountListItem {
-    private final Type type;
-    private LedgerAccount account;
-    public AccountListItem(@NotNull LedgerAccount account) {
-        this.type = Type.ACCOUNT;
-        this.account = account;
-    }
-    public AccountListItem() {
-        this.type = Type.HEADER;
-    }
+public abstract class AccountListItem {
+    private AccountListItem() {}
+    public abstract boolean sameContent(AccountListItem other);
     @NonNull
     public Type getType() {
-        return type;
+        if (this instanceof Account)
+            return Type.ACCOUNT;
+        else if (this instanceof Header)
+            return Type.HEADER;
+        else
+            throw new RuntimeException("Unsupported sub-class " + this);
+    }
+    public boolean isAccount() {
+        return this instanceof Account;
+    }
+    public Account toAccount() {
+        assert isAccount();
+        return ((Account) this);
+    }
+    public boolean isHeader() {
+        return this instanceof Header;
     }
-    @NotNull
-    public LedgerAccount getAccount() {
-        if (type != Type.ACCOUNT)
-            throw new IllegalStateException(
-                    String.format("Item type is not %s, but %s", Type.ACCOUNT, type));
-        return account;
+    public Header toHeader() {
+        assert isHeader();
+        return ((Header) this);
     }
     public enum Type {ACCOUNT, HEADER}
+
+    public static class Account extends AccountListItem {
+        private final LedgerAccount account;
+        public Account(@NotNull LedgerAccount account) {
+            this.account = account;
+        }
+        @Override
+        public boolean sameContent(AccountListItem other) {
+            if (!(other instanceof Account))
+                return false;
+            return ((Account) other).account.hasSubAccounts() == account.hasSubAccounts() &&
+                   ((Account) other).account.amountsExpanded() == account.amountsExpanded() &&
+                   ((Account) other).account.isExpanded() == account.isExpanded() &&
+                   ((Account) other).account.getLevel() == account.getLevel() &&
+                   ((Account) other).account.getAmountsString()
+                                            .equals(account.getAmountsString());
+        }
+        @NotNull
+        public LedgerAccount getAccount() {
+            return account;
+        }
+        public boolean allAmountsAreZero() {
+            return account.allAmountsAreZero();
+        }
+    }
+
+    public static class Header extends AccountListItem {
+        private final LiveData<String> text;
+        public Header(@NonNull LiveData<String> text) {
+            this.text = text;
+        }
+        public LiveData<String> getText() {
+            return text;
+        }
+        @Override
+        public boolean sameContent(AccountListItem other) {
+            return true;
+        }
+    }
 }
index 5f488e28634eb4ca72a156b05371be075747c0af..bfe84ea031355d93baf1fc03b375b3ec372a8cb0 100644 (file)
 
 package net.ktnx.mobileledger.model;
 
-import android.database.Cursor;
-import android.database.sqlite.SQLiteDatabase;
-
-import androidx.annotation.NonNull;
-import androidx.recyclerview.widget.DiffUtil;
-
-import net.ktnx.mobileledger.App;
 import net.ktnx.mobileledger.utils.Misc;
 
 public class Currency {
-    public static final DiffUtil.ItemCallback<Currency> DIFF_CALLBACK =
-            new DiffUtil.ItemCallback<Currency>() {
-                @Override
-                public boolean areItemsTheSame(@NonNull Currency oldItem,
-                                               @NonNull Currency newItem) {
-                    return oldItem.id == newItem.id;
-                }
-                @Override
-                public boolean areContentsTheSame(@NonNull Currency oldItem,
-                                                  @NonNull Currency newItem) {
-                    return oldItem.name.equals(newItem.name) &&
-                           oldItem.position.equals(newItem.position) &&
-                           (oldItem.hasGap == newItem.hasGap);
-                }
-            };
     private final int id;
     private String name;
     private Position position;
@@ -58,24 +36,6 @@ public class Currency {
         this.position = position;
         this.hasGap = hasGap;
     }
-    public Currency(MobileLedgerProfile profile, String name, Position position, boolean hasGap) {
-        SQLiteDatabase db = App.getDatabase();
-
-        try (Cursor c = db.rawQuery("select max(rowid) from currencies", null)) {
-            c.moveToNext();
-            this.id = c.getInt(0) + 1;
-        }
-        db.execSQL("insert into currencies(id, name, position, has_gap) values(?, ?, ?, ?)",
-                new Object[]{this.id, name, position.toString(), hasGap});
-
-        this.name = name;
-        this.position = position;
-        this.hasGap = hasGap;
-    }
-    public static Currency loadByName(String name) {
-        MobileLedgerProfile profile = Data.getProfile();
-        return profile.loadCurrencyByName(name);
-    }
     static public boolean equal(Currency left, Currency right) {
         if (left == null) {
             return right == null;
index f0ba846ec05d9271a7c4f390631cde6a9906c4e5..91a3e6d663881474bbe6231ad0564b063226e380 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2020 Damyan Ivanov.
+ * Copyright © 2021 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
 
 package net.ktnx.mobileledger.model;
 
-import android.database.Cursor;
-import android.database.sqlite.SQLiteDatabase;
-
-import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.lifecycle.LifecycleOwner;
+import androidx.lifecycle.LiveData;
 import androidx.lifecycle.MutableLiveData;
 import androidx.lifecycle.Observer;
 
-import net.ktnx.mobileledger.App;
 import net.ktnx.mobileledger.async.RetrieveTransactionsTask;
-import net.ktnx.mobileledger.utils.LockHolder;
+import net.ktnx.mobileledger.db.DB;
+import net.ktnx.mobileledger.db.Profile;
 import net.ktnx.mobileledger.utils.Locker;
 import net.ktnx.mobileledger.utils.Logger;
-import net.ktnx.mobileledger.utils.MLDB;
-import net.ktnx.mobileledger.utils.ObservableValue;
 
+import org.jetbrains.annotations.NotNull;
+
+import java.text.DecimalFormatSymbols;
 import java.text.NumberFormat;
-import java.util.ArrayList;
+import java.text.ParseException;
+import java.text.ParsePosition;
 import java.util.Date;
 import java.util.List;
 import java.util.Locale;
-import java.util.Objects;
 import java.util.concurrent.atomic.AtomicInteger;
 
 import static net.ktnx.mobileledger.utils.Logger.debug;
@@ -49,8 +47,9 @@ public final class Data {
             new MutableLiveData<>(false);
     public static final MutableLiveData<RetrieveTransactionsTask.Progress> backgroundTaskProgress =
             new MutableLiveData<>();
-    public static final MutableLiveData<ArrayList<MobileLedgerProfile>> profiles =
-            new MutableLiveData<>(null);
+    public static final LiveData<List<Profile>> profiles = DB.get()
+                                                             .getProfileDAO()
+                                                             .getAllOrdered();
     public static final MutableLiveData<Currency.Position> currencySymbolPosition =
             new MutableLiveData<>();
     public static final MutableLiveData<Boolean> currencyGap = new MutableLiveData<>(true);
@@ -60,21 +59,27 @@ public final class Data {
     public static final MutableLiveData<Integer> lastUpdateTransactionCount =
             new MutableLiveData<>(0);
     public static final MutableLiveData<Integer> lastUpdateAccountCount = new MutableLiveData<>(0);
-    public static final ObservableValue<String> lastTransactionsUpdateText =
-            new ObservableValue<>();
-    public static final ObservableValue<String> lastAccountsUpdateText = new ObservableValue<>();
-    private static final MutableLiveData<MobileLedgerProfile> profile =
-            new InertMutableLiveData<>();
+    public static final MutableLiveData<String> lastTransactionsUpdateText =
+            new MutableLiveData<>();
+    public static final MutableLiveData<String> lastAccountsUpdateText = new MutableLiveData<>();
+    public static final String decimalDot = ".";
+
+    private static final MutableLiveData<Profile> profile = new MutableLiveData<>();
     private static final AtomicInteger backgroundTaskCount = new AtomicInteger(0);
     private static final Locker profilesLocker = new Locker();
+    private static NumberFormat numberFormatter;
+    private static String decimalSeparator = "";
 
     static {
         locale.setValue(Locale.getDefault());
     }
 
-    @NonNull
-    public static MobileLedgerProfile getProfile() {
-        return Objects.requireNonNull(profile.getValue());
+    public static String getDecimalSeparator() {
+        return decimalSeparator;
+    }
+    @Nullable
+    public static Profile getProfile() {
+        return profile.getValue();
     }
     public static void backgroundTaskStarted() {
         int cnt = backgroundTaskCount.incrementAndGet();
@@ -90,74 +95,11 @@ public final class Data {
                         cnt));
         backgroundTasksRunning.postValue(cnt > 0);
     }
-    public static void setCurrentProfile(@NonNull MobileLedgerProfile newProfile) {
-        MLDB.setOption(MLDB.OPT_PROFILE_UUID, newProfile.getUuid());
-        profile.postValue(newProfile);
-    }
-    public static int getProfileIndex(MobileLedgerProfile profile) {
-        try (LockHolder ignored = profilesLocker.lockForReading()) {
-            List<MobileLedgerProfile> prList = profiles.getValue();
-            if (prList == null)
-                throw new AssertionError();
-            for (int i = 0; i < prList.size(); i++) {
-                MobileLedgerProfile p = prList.get(i);
-                if (p.equals(profile))
-                    return i;
-            }
-
-            return -1;
-        }
-    }
-    @SuppressWarnings("WeakerAccess")
-    public static int getProfileIndex(String profileUUID) {
-        try (LockHolder ignored = profilesLocker.lockForReading()) {
-            List<MobileLedgerProfile> prList = profiles.getValue();
-            if (prList == null)
-                throw new AssertionError();
-            for (int i = 0; i < prList.size(); i++) {
-                MobileLedgerProfile p = prList.get(i);
-                if (p.getUuid()
-                     .equals(profileUUID))
-                    return i;
-            }
-
-            return -1;
-        }
-    }
-    public static int retrieveCurrentThemeIdFromDb() {
-        String profileUUID = MLDB.getOption(MLDB.OPT_PROFILE_UUID, null);
-        if (profileUUID == null)
-            return -1;
-
-        SQLiteDatabase db = App.getDatabase();
-        try (Cursor c = db.rawQuery("SELECT theme from profiles where uuid=?",
-                new String[]{profileUUID}))
-        {
-            if (c.moveToNext())
-                return c.getInt(0);
-        }
-
-        return -1;
+    public static void setCurrentProfile(Profile newProfile) {
+        profile.setValue(newProfile);
     }
-    @Nullable
-    public static MobileLedgerProfile getProfile(String profileUUID) {
-        MobileLedgerProfile profile;
-        try (LockHolder readLock = profilesLocker.lockForReading()) {
-            List<MobileLedgerProfile> prList = profiles.getValue();
-            if ((prList == null) || prList.isEmpty()) {
-                readLock.close();
-                try (LockHolder ignored = profilesLocker.lockForWriting()) {
-                    profile = MobileLedgerProfile.loadAllFromDB(profileUUID);
-                }
-            }
-            else {
-                int i = getProfileIndex(profileUUID);
-                if (i == -1)
-                    i = 0;
-                profile = prList.get(i);
-            }
-        }
-        return profile;
+    public static void postCurrentProfile(Profile newProfile) {
+        profile.postValue(newProfile);
     }
     public static void refreshCurrencyData(Locale locale) {
         NumberFormat formatter = NumberFormat.getCurrencyInstance(locale);
@@ -185,25 +127,39 @@ public final class Data {
         }
         else
             currencySymbolPosition.setValue(Currency.Position.none);
-    }
 
-    public static void observeProfile(LifecycleOwner lifecycleOwner,
-                                      Observer<MobileLedgerProfile> observer) {
-        profile.observe(lifecycleOwner, observer);
+        NumberFormat newNumberFormatter = NumberFormat.getNumberInstance();
+        newNumberFormatter.setParseIntegerOnly(false);
+        newNumberFormatter.setGroupingUsed(true);
+        newNumberFormatter.setMinimumIntegerDigits(1);
+        newNumberFormatter.setMinimumFractionDigits(2);
+
+        numberFormatter = newNumberFormatter;
+
+        decimalSeparator = String.valueOf(DecimalFormatSymbols.getInstance(locale)
+                                                              .getMonetaryDecimalSeparator());
     }
-    public synchronized static MobileLedgerProfile initProfile() {
-        MobileLedgerProfile currentProfile = profile.getValue();
-        if (currentProfile != null)
-            return currentProfile;
-
-        String profileUUID = MLDB.getOption(MLDB.OPT_PROFILE_UUID, null);
-        MobileLedgerProfile startupProfile = getProfile(profileUUID);
-        if (startupProfile != null)
-            setCurrentProfile(startupProfile);
-        return startupProfile;
+    @NotNull
+    public static String formatCurrency(float number) {
+        NumberFormat formatter = NumberFormat.getCurrencyInstance(locale.getValue());
+        return formatter.format(number);
+    }
+    @NotNull
+    public static String formatNumber(float number) {
+        return numberFormatter.format(number);
+    }
+    public static void observeProfile(LifecycleOwner lifecycleOwner, Observer<Profile> observer) {
+        profile.observe(lifecycleOwner, observer);
     }
-
     public static void removeProfileObservers(LifecycleOwner owner) {
         profile.removeObservers(owner);
     }
+    public static float parseNumber(String str) throws ParseException {
+        ParsePosition pos = new ParsePosition(0);
+        Number parsed = numberFormatter.parse(str);
+        if (parsed == null || pos.getErrorIndex() > -1)
+            throw new ParseException("Error parsing '" + str + "'", pos.getErrorIndex());
+
+        return parsed.floatValue();
+    }
 }
\ No newline at end of file
diff --git a/app/src/main/java/net/ktnx/mobileledger/model/FutureDates.java b/app/src/main/java/net/ktnx/mobileledger/model/FutureDates.java
new file mode 100644 (file)
index 0000000..db80db1
--- /dev/null
@@ -0,0 +1,68 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.model;
+
+import android.content.res.Resources;
+import android.util.SparseArray;
+
+import net.ktnx.mobileledger.R;
+
+public enum FutureDates {
+    None(0), OneWeek(7), TwoWeeks(14), OneMonth(30), TwoMonths(60), ThreeMonths(90),
+    SixMonths(180), OneYear(365), All(-1);
+    private static final SparseArray<FutureDates> map = new SparseArray<>();
+
+    static {
+        for (FutureDates item : FutureDates.values()) {
+            map.put(item.value, item);
+        }
+    }
+
+    private final int value;
+    FutureDates(int value) {
+        this.value = value;
+    }
+    public static FutureDates valueOf(int i) {
+        return map.get(i, None);
+    }
+    public int toInt() {
+        return this.value;
+    }
+    public String getText(Resources resources) {
+        switch (value) {
+            case 7:
+                return resources.getString(R.string.future_dates_7);
+            case 14:
+                return resources.getString(R.string.future_dates_14);
+            case 30:
+                return resources.getString(R.string.future_dates_30);
+            case 60:
+                return resources.getString(R.string.future_dates_60);
+            case 90:
+                return resources.getString(R.string.future_dates_90);
+            case 180:
+                return resources.getString(R.string.future_dates_180);
+            case 365:
+                return resources.getString(R.string.future_dates_365);
+            case -1:
+                return resources.getString(R.string.future_dates_all);
+            default:
+                return resources.getString(R.string.future_dates_none);
+        }
+    }
+}
index f3cbba213e4218e0e8dfdb68d4f2528d92144a89..235f8c729bdfde5e73a8d89c8bd23936ccd02a9f 100644 (file)
@@ -20,38 +20,41 @@ package net.ktnx.mobileledger.model;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 
+import net.ktnx.mobileledger.json.API;
+
 import java.util.Locale;
 
 public class HledgerVersion {
-    private int major;
-    private int minor;
-    private int patch;
-    private boolean isPre_1_20;
-    private boolean hasPatch;
+    private final int major;
+    private final int minor;
+    private final int patch;
+    private final boolean isPre_1_20_1;
+    private final boolean hasPatch;
     public HledgerVersion(int major, int minor) {
         this.major = major;
         this.minor = minor;
-        this.isPre_1_20 = false;
+        this.patch = 0;
+        this.isPre_1_20_1 = false;
         this.hasPatch = false;
     }
     public HledgerVersion(int major, int minor, int patch) {
         this.major = major;
         this.minor = minor;
         this.patch = patch;
-        this.isPre_1_20 = false;
+        this.isPre_1_20_1 = false;
         this.hasPatch = true;
     }
-    public HledgerVersion(boolean pre_1_20) {
-        if (!pre_1_20)
-            throw new IllegalArgumentException("pre_1_20 argument must be true");
-        this.major = this.minor = 0;
-        this.isPre_1_20 = true;
+    public HledgerVersion(boolean pre_1_20_1) {
+        if (!pre_1_20_1)
+            throw new IllegalArgumentException("pre_1_20_1 argument must be true");
+        this.major = this.minor = this.patch = 0;
+        this.isPre_1_20_1 = true;
         this.hasPatch = false;
     }
     public HledgerVersion(HledgerVersion origin) {
         this.major = origin.major;
         this.minor = origin.minor;
-        this.isPre_1_20 = origin.isPre_1_20;
+        this.isPre_1_20_1 = origin.isPre_1_20_1;
         this.patch = origin.patch;
         this.hasPatch = origin.hasPatch;
     }
@@ -63,12 +66,12 @@ public class HledgerVersion {
             return false;
         HledgerVersion that = (HledgerVersion) obj;
 
-        return (this.isPre_1_20 == that.isPre_1_20 && this.major == that.major &&
+        return (this.isPre_1_20_1 == that.isPre_1_20_1 && this.major == that.major &&
                 this.minor == that.minor && this.patch == that.patch &&
                 this.hasPatch == that.hasPatch);
     }
-    public boolean isPre_1_20() {
-        return isPre_1_20;
+    public boolean isPre_1_20_1() {
+        return isPre_1_20_1;
     }
     public int getMajor() {
         return major;
@@ -82,9 +85,19 @@ public class HledgerVersion {
     @NonNull
     @Override
     public String toString() {
-        if (isPre_1_20)
+        if (isPre_1_20_1)
             return "(before 1.20)";
         return hasPatch ? String.format(Locale.ROOT, "%d.%d.%d", major, minor, patch)
                         : String.format(Locale.ROOT, "%d.%d", major, minor);
     }
+    public boolean atLeast(int major, int minor) {
+        return ((this.major == major) && (this.minor >= minor)) || (this.major > major);
+    }
+    @org.jetbrains.annotations.Nullable
+    public API getSuitableApiVersion() {
+        if (isPre_1_20_1)
+            return null;
+
+        return API.v1_19_1;
+    }
 }
index 7fb38649a1e46571515917e59fcbabf4a2811950..c2e62772f5990ef911854c838218edde70e50f82 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2020 Damyan Ivanov.
+ * Copyright © 2024 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
@@ -20,25 +20,29 @@ package net.ktnx.mobileledger.model;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 
-import java.lang.ref.WeakReference;
+import net.ktnx.mobileledger.db.Account;
+import net.ktnx.mobileledger.db.AccountValue;
+import net.ktnx.mobileledger.db.AccountWithAmounts;
+
 import java.util.ArrayList;
 import java.util.List;
 import java.util.regex.Pattern;
 
 public class LedgerAccount {
+    private static final char ACCOUNT_DELIMITER = ':';
     static Pattern reHigherAccount = Pattern.compile("^[^:]+:");
+    private final LedgerAccount parent;
+    private long dbId;
+    private long profileId;
     private String name;
     private String shortName;
     private int level;
-    private final LedgerAccount parent;
     private boolean expanded;
     private List<LedgerAmount> amounts;
     private boolean hasSubAccounts;
     private boolean amountsExpanded;
-    private final WeakReference<MobileLedgerProfile> profileWeakReference;
 
-    public LedgerAccount(MobileLedgerProfile profile, String name, @Nullable LedgerAccount parent) {
-        this.profileWeakReference = new WeakReference<>(profile);
+    public LedgerAccount(String name, @Nullable LedgerAccount parent) {
         this.parent = parent;
         if (parent != null && !name.startsWith(parent.getName() + ":"))
             throw new IllegalStateException(
@@ -48,15 +52,39 @@ public class LedgerAccount {
     }
     @Nullable
     public static String extractParentName(@NonNull String accName) {
-        int colonPos = accName.lastIndexOf(':');
+        int colonPos = accName.lastIndexOf(ACCOUNT_DELIMITER);
         if (colonPos < 0)
             return null;    // no parent account -- this is a top-level account
         else
             return accName.substring(0, colonPos);
     }
-    public @Nullable
-    MobileLedgerProfile getProfile() {
-        return profileWeakReference.get();
+    public static boolean isParentOf(@NonNull String possibleParent, @NonNull String accountName) {
+        return accountName.startsWith(possibleParent + ':');
+    }
+    @NonNull
+    static public LedgerAccount fromDBO(AccountWithAmounts in, LedgerAccount parent) {
+        LedgerAccount res = new LedgerAccount(in.account.getName(), parent);
+        res.dbId = in.account.getId();
+        res.profileId = in.account.getProfileId();
+        res.setName(in.account.getName());
+        res.setExpanded(in.account.isExpanded());
+        res.setAmountsExpanded(in.account.isAmountsExpanded());
+
+        res.amounts = new ArrayList<>();
+        for (AccountValue val : in.amounts) {
+            res.amounts.add(new LedgerAmount(val.getValue(), val.getCurrency()));
+        }
+
+        return res;
+    }
+    public static int determineLevel(String accName) {
+        int level = 0;
+        int delimiterPosition = accName.indexOf(ACCOUNT_DELIMITER);
+        while (delimiterPosition >= 0) {
+            level++;
+            delimiterPosition = accName.indexOf(ACCOUNT_DELIMITER, delimiterPosition + 1);
+        }
+        return level;
     }
     @Override
     public int hashCode() {
@@ -92,15 +120,9 @@ public class LedgerAccount {
                              .startsWith(name + ":");
     }
     private void stripName() {
-        if (parent == null) {
-            level = 0;
-            shortName = name;
-        }
-        else {
-            level = parent.level + 1;
-            shortName = name.substring(parent.getName()
-                                             .length() + 1);
-        }
+        String[] split = name.split(":");
+        shortName = split[split.length - 1];
+        level = split.length - 1;
     }
     public String getName() {
         return name;
@@ -153,12 +175,10 @@ public class LedgerAccount {
     public int getLevel() {
         return level;
     }
-
     @NonNull
     public String getShortName() {
         return shortName;
     }
-
     public String getParentName() {
         return (parent == null) ? null : parent.getName();
     }
@@ -181,15 +201,54 @@ public class LedgerAccount {
         if (amounts != null)
             amounts.clear();
     }
-    public boolean amountsExpanded() { return amountsExpanded; }
-    public void setAmountsExpanded(boolean flag) { amountsExpanded = flag; }
-    public void toggleAmountsExpanded() { amountsExpanded = !amountsExpanded; }
-
+    public boolean amountsExpanded() {return amountsExpanded;}
+    public void setAmountsExpanded(boolean flag) {amountsExpanded = flag;}
+    public void toggleAmountsExpanded() {amountsExpanded = !amountsExpanded;}
     public void propagateAmountsTo(LedgerAccount acc) {
         for (LedgerAmount a : amounts)
             a.propagateToAccount(acc);
     }
+    public boolean allAmountsAreZero() {
+        for (LedgerAmount a : amounts) {
+            if (a.getAmount() != 0)
+                return false;
+        }
+
+        return true;
+    }
     public List<LedgerAmount> getAmounts() {
         return amounts;
     }
+    @NonNull
+    public Account toDBO() {
+        Account dbo = new Account();
+        dbo.setName(name);
+        dbo.setNameUpper(name.toUpperCase());
+        dbo.setParentName(extractParentName(name));
+        dbo.setLevel(level);
+        dbo.setId(dbId);
+        dbo.setProfileId(profileId);
+        dbo.setExpanded(expanded);
+        dbo.setAmountsExpanded(amountsExpanded);
+
+        return dbo;
+    }
+    @NonNull
+    public AccountWithAmounts toDBOWithAmounts() {
+        AccountWithAmounts dbo = new AccountWithAmounts();
+        dbo.account = toDBO();
+
+        dbo.amounts = new ArrayList<>();
+        for (LedgerAmount amt : getAmounts()) {
+            AccountValue val = new AccountValue();
+            val.setCurrency(amt.getCurrency());
+            val.setValue(amt.getAmount());
+            dbo.amounts.add(val);
+        }
+
+        return dbo;
+    }
+    public long getId() {
+        return dbId;
+    }
 }
index 0a65e139530b5e02dc933c754029dc9d8875fc97..4e3cb8d21d34d4068dc007df6ab3da79bcaf0e4f 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2020 Damyan Ivanov.
+ * Copyright © 2021 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
@@ -21,20 +21,41 @@ import android.annotation.SuppressLint;
 
 import androidx.annotation.NonNull;
 
+import net.ktnx.mobileledger.dao.AccountValueDAO;
+import net.ktnx.mobileledger.db.Account;
+import net.ktnx.mobileledger.db.AccountValue;
+import net.ktnx.mobileledger.db.DB;
+
 public class LedgerAmount {
     private final String currency;
     private final float amount;
+    private long dbId;
 
     public LedgerAmount(float amount, @NonNull String currency) {
         this.currency = currency;
         this.amount = amount;
     }
-
     public LedgerAmount(float amount) {
         this.amount = amount;
         this.currency = null;
     }
+    static public LedgerAmount fromDBO(AccountValue dbo) {
+        final LedgerAmount ledgerAmount = new LedgerAmount(dbo.getValue(), dbo.getCurrency());
+        ledgerAmount.dbId = dbo.getId();
+        return ledgerAmount;
+    }
+    public AccountValue toDBO(Account account) {
+        final AccountValueDAO dao = DB.get()
+                                      .getAccountValueDAO();
+        AccountValue obj = new AccountValue();
+        obj.setId(dbId);
+        obj.setAccountId(account.getId());
 
+        obj.setCurrency(currency);
+        obj.setValue(amount);
+
+        return obj;
+    }
     @SuppressLint("DefaultLocale")
     @NonNull
     public String toString() {
index e566ca8f06fb8f5373ee15036f3c868f4895d086..cef83169f7a35ec21b304f08eb7c0b5b9dd25983 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2020 Damyan Ivanov.
+ * Copyright © 2021 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
 
 package net.ktnx.mobileledger.model;
 
-import android.database.Cursor;
-import android.database.sqlite.SQLiteDatabase;
-
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 
-import net.ktnx.mobileledger.App;
+import net.ktnx.mobileledger.db.Profile;
+import net.ktnx.mobileledger.db.Transaction;
+import net.ktnx.mobileledger.db.TransactionAccount;
+import net.ktnx.mobileledger.db.TransactionWithAccounts;
 import net.ktnx.mobileledger.utils.Digest;
 import net.ktnx.mobileledger.utils.Globals;
 import net.ktnx.mobileledger.utils.SimpleDate;
@@ -52,40 +52,78 @@ public class LedgerTransaction {
             return res;
         return Float.compare(o1.getAmount(), o2.getAmount());
     };
-    private final String profile;
-    private final Integer id;
+    private final long profile;
+    private final long ledgerId;
+    private final List<LedgerTransactionAccount> accounts;
+    private long dbId;
     private SimpleDate date;
     private String description;
     private String comment;
-    private final List<LedgerTransactionAccount> accounts;
     private String dataHash;
     private boolean dataLoaded;
-    public LedgerTransaction(Integer id, String dateString, String description)
+    public LedgerTransaction(long ledgerId, String dateString, String description)
             throws ParseException {
-        this(id, Globals.parseLedgerDate(dateString), description);
+        this(ledgerId, Globals.parseLedgerDate(dateString), description);
+    }
+    public LedgerTransaction(TransactionWithAccounts dbo) {
+        this(dbo.transaction.getLedgerId(), dbo.transaction.getProfileId());
+        dbId = dbo.transaction.getId();
+        date = new SimpleDate(dbo.transaction.getYear(), dbo.transaction.getMonth(),
+                dbo.transaction.getDay());
+        description = dbo.transaction.getDescription();
+        comment = dbo.transaction.getComment();
+        dataHash = dbo.transaction.getDataHash();
+        if (dbo.accounts != null)
+            for (TransactionAccount acc : dbo.accounts) {
+                accounts.add(new LedgerTransactionAccount(acc));
+            }
+        dataLoaded = true;
+    }
+    public TransactionWithAccounts toDBO() {
+        TransactionWithAccounts o = new TransactionWithAccounts();
+        o.transaction = new Transaction();
+        o.transaction.setId(dbId);
+        o.transaction.setProfileId(profile);
+        o.transaction.setLedgerId(ledgerId);
+        o.transaction.setYear(date.year);
+        o.transaction.setMonth(date.month);
+        o.transaction.setDay(date.day);
+        o.transaction.setDescription(description);
+        o.transaction.setComment(comment);
+        fillDataHash();
+        o.transaction.setDataHash(dataHash);
+
+        o.accounts = new ArrayList<>();
+        int orderNo = 1;
+        for (LedgerTransactionAccount acc : accounts) {
+            TransactionAccount a = acc.toDBO();
+            a.setOrderNo(orderNo++);
+            a.setTransactionId(dbId);
+            o.accounts.add(a);
+        }
+        return o;
     }
-    public LedgerTransaction(Integer id, SimpleDate date, String description,
-                             MobileLedgerProfile profile) {
-        this.profile = profile.getUuid();
-        this.id = id;
+    public LedgerTransaction(long ledgerId, SimpleDate date, String description, Profile profile) {
+        this.profile = profile.getId();
+        this.ledgerId = ledgerId;
         this.date = date;
         this.description = description;
         this.accounts = new ArrayList<>();
         this.dataHash = null;
         dataLoaded = false;
     }
-    public LedgerTransaction(Integer id, SimpleDate date, String description) {
-        this(id, date, description, Data.getProfile());
+    public LedgerTransaction(long ledgerId, SimpleDate date, String description) {
+        this(ledgerId, date, description, Data.getProfile());
     }
     public LedgerTransaction(SimpleDate date, String description) {
-        this(null, date, description);
+        this(0, date, description);
     }
-    public LedgerTransaction(int id) {
-        this(id, (SimpleDate) null, null);
+    public LedgerTransaction(int ledgerId) {
+        this(ledgerId, (SimpleDate) null, null);
     }
-    public LedgerTransaction(int id, String profileUUID) {
-        this.profile = profileUUID;
-        this.id = id;
+    public LedgerTransaction(long ledgerId, long profileId) {
+        this.profile = profileId;
+        this.ledgerId = ledgerId;
         this.date = null;
         this.description = null;
         this.accounts = new ArrayList<>();
@@ -126,11 +164,10 @@ public class LedgerTransaction {
     public void setComment(String comment) {
         this.comment = comment;
     }
-    public int getId() {
-        return id;
+    public long getLedgerId() {
+        return ledgerId;
     }
     protected void fillDataHash() {
-        loadData(App.getDatabase());
         if (dataHash != null)
             return;
         try {
@@ -138,7 +175,7 @@ public class LedgerTransaction {
             StringBuilder data = new StringBuilder();
             data.append("ver1");
             data.append(profile);
-            data.append(getId());
+            data.append(getLedgerId());
             data.append('\0');
             data.append(getDescription());
             data.append('\0');
@@ -164,40 +201,6 @@ public class LedgerTransaction {
                     String.format("Unable to get instance of %s digest", DIGEST_TYPE), e);
         }
     }
-    public synchronized void loadData(SQLiteDatabase db) {
-        if (dataLoaded)
-            return;
-
-        try (Cursor cTr = db.rawQuery(
-                "SELECT year, month, day, description, comment from transactions WHERE profile=? " +
-                "AND id=?", new String[]{profile, String.valueOf(id)}))
-        {
-            if (cTr.moveToFirst()) {
-                date = new SimpleDate(cTr.getInt(0), cTr.getInt(1), cTr.getInt(2));
-                description = cTr.getString(3);
-                comment = cTr.getString(4);
-
-                accounts.clear();
-
-                try (Cursor cAcc = db.rawQuery(
-                        "SELECT account_name, amount, currency, comment FROM " +
-                        "transaction_accounts WHERE profile=? AND transaction_id = ?",
-                        new String[]{profile, String.valueOf(id)}))
-                {
-                    while (cAcc.moveToNext()) {
-//                        debug("transactions",
-//                                String.format("Loaded %d: %s %1.2f %s", id, cAcc.getString(0),
-//                                        cAcc.getFloat(1), cAcc.getString(2)));
-                        addAccount(new LedgerTransactionAccount(cAcc.getString(0), cAcc.getFloat(1),
-                                cAcc.getString(2), cAcc.getString(3)));
-                    }
-
-                    finishLoading();
-                }
-            }
-        }
-
-    }
     public String getDataHash() {
         fillDataHash();
         return dataHash;
index 1791cfbc5962e79b5b3c16ab4516c5e921a1765b..9706dbb5633e0d8428c98444156a942ee4cd9bd5 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2019 Damyan Ivanov.
+ * Copyright © 2021 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
@@ -18,7 +18,9 @@
 package net.ktnx.mobileledger.model;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 
+import net.ktnx.mobileledger.db.TransactionAccount;
 import net.ktnx.mobileledger.utils.Misc;
 
 import java.util.Locale;
@@ -28,9 +30,11 @@ public class LedgerTransactionAccount {
     private String shortAccountName;
     private float amount;
     private boolean amountSet = false;
+    @Nullable
     private String currency;
     private String comment;
     private boolean amountValid = true;
+    private long dbId;
     public LedgerTransactionAccount(String accountName, float amount, String currency,
                                     String comment) {
         this.setAccountName(accountName);
@@ -56,6 +60,13 @@ public class LedgerTransactionAccount {
         amountValid = origin.amountValid;
         currency = origin.getCurrency();
     }
+    public LedgerTransactionAccount(TransactionAccount dbo) {
+        this(dbo.getAccountName(), dbo.getAmount(), Misc.emptyIsNull(dbo.getCurrency()),
+                Misc.emptyIsNull(dbo.getComment()));
+        amountSet = true;
+        amountValid = true;
+        dbId = dbo.getId();
+    }
     public String getComment() {
         return comment;
     }
@@ -94,6 +105,7 @@ public class LedgerTransactionAccount {
         return amountSet;
     }
     public boolean isAmountValid() { return amountValid; }
+    @Nullable
     public String getCurrency() {
         return currency;
     }
@@ -114,4 +126,15 @@ public class LedgerTransactionAccount {
 
         return sb.toString();
     }
+    public TransactionAccount toDBO() {
+        TransactionAccount dbo = new TransactionAccount();
+        dbo.setAccountName(accountName);
+        if (amountSet)
+            dbo.setAmount(amount);
+        dbo.setComment(comment);
+        dbo.setCurrency(Misc.nullIsEmpty(currency));
+        dbo.setId(dbId);
+
+        return dbo;
+    }
 }
\ No newline at end of file
diff --git a/app/src/main/java/net/ktnx/mobileledger/model/MatchedTemplate.java b/app/src/main/java/net/ktnx/mobileledger/model/MatchedTemplate.java
new file mode 100644 (file)
index 0000000..607d6c7
--- /dev/null
@@ -0,0 +1,31 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.model;
+
+import net.ktnx.mobileledger.db.TemplateHeader;
+
+import java.util.regex.MatchResult;
+
+public class MatchedTemplate {
+    public TemplateHeader templateHead;
+    public MatchResult matchResult;
+    public MatchedTemplate(TemplateHeader templateHead, MatchResult matchResult) {
+        this.templateHead = templateHead;
+        this.matchResult = matchResult;
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/model/MobileLedgerProfile.java b/app/src/main/java/net/ktnx/mobileledger/model/MobileLedgerProfile.java
deleted file mode 100644 (file)
index 55746dd..0000000
+++ /dev/null
@@ -1,768 +0,0 @@
-/*
- * Copyright © 2020 Damyan Ivanov.
- * This file is part of MoLe.
- * MoLe is free software: you can distribute it and/or modify it
- * under the term of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your opinion), any later version.
- *
- * MoLe is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License terms for details.
- *
- * You should have received a copy of the GNU General Public License
- * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package net.ktnx.mobileledger.model;
-
-import android.content.res.Resources;
-import android.database.Cursor;
-import android.database.sqlite.SQLiteDatabase;
-import android.text.TextUtils;
-import android.util.SparseArray;
-
-import androidx.annotation.Nullable;
-
-import net.ktnx.mobileledger.App;
-import net.ktnx.mobileledger.R;
-import net.ktnx.mobileledger.async.DbOpQueue;
-import net.ktnx.mobileledger.async.SendTransactionTask;
-import net.ktnx.mobileledger.utils.Logger;
-import net.ktnx.mobileledger.utils.Misc;
-import net.ktnx.mobileledger.utils.SimpleDate;
-
-import org.jetbrains.annotations.Contract;
-
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import java.util.Objects;
-
-import static net.ktnx.mobileledger.utils.Logger.debug;
-
-public final class MobileLedgerProfile {
-    // N.B. when adding new fields, update the copy-constructor below
-    private final String uuid;
-    private String name;
-    private boolean permitPosting;
-    private boolean showCommentsByDefault;
-    private boolean showCommodityByDefault;
-    private String defaultCommodity;
-    private String preferredAccountsFilter;
-    private String url;
-    private boolean authEnabled;
-    private String authUserName;
-    private String authPassword;
-    private int themeHue;
-    private int orderNo = -1;
-    private SendTransactionTask.API apiVersion = SendTransactionTask.API.auto;
-    private FutureDates futureDates = FutureDates.None;
-    private boolean accountsLoaded;
-    private boolean transactionsLoaded;
-    private HledgerVersion detectedVersion;
-    // N.B. when adding new fields, update the copy-constructor below
-    transient private AccountAndTransactionListSaver accountAndTransactionListSaver;
-    public MobileLedgerProfile(String uuid) {
-        this.uuid = uuid;
-    }
-    public MobileLedgerProfile(MobileLedgerProfile origin) {
-        uuid = origin.uuid;
-        name = origin.name;
-        permitPosting = origin.permitPosting;
-        showCommentsByDefault = origin.showCommentsByDefault;
-        showCommodityByDefault = origin.showCommodityByDefault;
-        preferredAccountsFilter = origin.preferredAccountsFilter;
-        url = origin.url;
-        authEnabled = origin.authEnabled;
-        authUserName = origin.authUserName;
-        authPassword = origin.authPassword;
-        themeHue = origin.themeHue;
-        orderNo = origin.orderNo;
-        futureDates = origin.futureDates;
-        apiVersion = origin.apiVersion;
-        defaultCommodity = origin.defaultCommodity;
-        accountsLoaded = origin.accountsLoaded;
-        transactionsLoaded = origin.transactionsLoaded;
-        if (origin.detectedVersion != null)
-            detectedVersion = new HledgerVersion(origin.detectedVersion);
-    }
-    // loads all profiles into Data.profiles
-    // returns the profile with the given UUID
-    public static MobileLedgerProfile loadAllFromDB(@Nullable String currentProfileUUID) {
-        MobileLedgerProfile result = null;
-        ArrayList<MobileLedgerProfile> list = new ArrayList<>();
-        SQLiteDatabase db = App.getDatabase();
-        try (Cursor cursor = db.rawQuery("SELECT uuid, name, url, use_authentication, auth_user, " +
-                                         "auth_password, permit_posting, theme, order_no, " +
-                                         "preferred_accounts_filter, future_dates, api_version, " +
-                                         "show_commodity_by_default, default_commodity, " +
-                                         "show_comments_by_default, detected_version_pre_1_19, " +
-                                         "detected_version_major, detected_version_minor FROM " +
-                                         "profiles order by order_no", null))
-        {
-            while (cursor.moveToNext()) {
-                MobileLedgerProfile item = new MobileLedgerProfile(cursor.getString(0));
-                item.setName(cursor.getString(1));
-                item.setUrl(cursor.getString(2));
-                item.setAuthEnabled(cursor.getInt(3) == 1);
-                item.setAuthUserName(cursor.getString(4));
-                item.setAuthPassword(cursor.getString(5));
-                item.setPostingPermitted(cursor.getInt(6) == 1);
-                item.setThemeId(cursor.getInt(7));
-                item.orderNo = cursor.getInt(8);
-                item.setPreferredAccountsFilter(cursor.getString(9));
-                item.setFutureDates(cursor.getInt(10));
-                item.setApiVersion(cursor.getInt(11));
-                item.setShowCommodityByDefault(cursor.getInt(12) == 1);
-                item.setDefaultCommodity(cursor.getString(13));
-                item.setShowCommentsByDefault(cursor.getInt(14) == 1);
-                {
-                    boolean pre_1_20 = cursor.getInt(15) == 1;
-                    int major = cursor.getInt(16);
-                    int minor = cursor.getInt(17);
-
-                    if (!pre_1_20 && major == 0 && minor == 0) {
-                        item.detectedVersion = null;
-                    }
-                    else if (pre_1_20) {
-                        item.detectedVersion = new HledgerVersion(true);
-                    }
-                    else {
-                        item.detectedVersion = new HledgerVersion(major, minor);
-                    }
-                }
-                list.add(item);
-                if (item.getUuid()
-                        .equals(currentProfileUUID))
-                    result = item;
-            }
-        }
-        Data.profiles.postValue(list);
-        return result;
-    }
-    public static void storeProfilesOrder() {
-        SQLiteDatabase db = App.getDatabase();
-        db.beginTransactionNonExclusive();
-        try {
-            int orderNo = 0;
-            for (MobileLedgerProfile p : Objects.requireNonNull(Data.profiles.getValue())) {
-                db.execSQL("update profiles set order_no=? where uuid=?",
-                        new Object[]{orderNo, p.getUuid()});
-                p.orderNo = orderNo;
-                orderNo++;
-            }
-            db.setTransactionSuccessful();
-        }
-        finally {
-            db.endTransaction();
-        }
-    }
-    public HledgerVersion getDetectedVersion() {
-        return detectedVersion;
-    }
-    public void setDetectedVersion(HledgerVersion detectedVersion) {
-        this.detectedVersion = detectedVersion;
-    }
-    @Contract(value = "null -> false", pure = true)
-    @Override
-    public boolean equals(@Nullable Object obj) {
-        if (obj == null)
-            return false;
-        if (obj == this)
-            return true;
-        if (obj.getClass() != this.getClass())
-            return false;
-
-        MobileLedgerProfile p = (MobileLedgerProfile) obj;
-        if (!uuid.equals(p.uuid))
-            return false;
-        if (!name.equals(p.name))
-            return false;
-        if (permitPosting != p.permitPosting)
-            return false;
-        if (showCommentsByDefault != p.showCommentsByDefault)
-            return false;
-        if (showCommodityByDefault != p.showCommodityByDefault)
-            return false;
-        if (!Objects.equals(defaultCommodity, p.defaultCommodity))
-            return false;
-        if (!Objects.equals(preferredAccountsFilter, p.preferredAccountsFilter))
-            return false;
-        if (!Objects.equals(url, p.url))
-            return false;
-        if (authEnabled != p.authEnabled)
-            return false;
-        if (!Objects.equals(authUserName, p.authUserName))
-            return false;
-        if (!Objects.equals(authPassword, p.authPassword))
-            return false;
-        if (themeHue != p.themeHue)
-            return false;
-        if (apiVersion != p.apiVersion)
-            return false;
-        if (!Objects.equals(detectedVersion, p.detectedVersion))
-            return false;
-        return futureDates == p.futureDates;
-    }
-    public boolean getShowCommentsByDefault() {
-        return showCommentsByDefault;
-    }
-    public void setShowCommentsByDefault(boolean newValue) {
-        this.showCommentsByDefault = newValue;
-    }
-    public boolean getShowCommodityByDefault() {
-        return showCommodityByDefault;
-    }
-    public void setShowCommodityByDefault(boolean showCommodityByDefault) {
-        this.showCommodityByDefault = showCommodityByDefault;
-    }
-    public String getDefaultCommodity() {
-        return defaultCommodity;
-    }
-    public void setDefaultCommodity(String defaultCommodity) {
-        this.defaultCommodity = defaultCommodity;
-    }
-    public void setDefaultCommodity(CharSequence defaultCommodity) {
-        if (defaultCommodity == null)
-            this.defaultCommodity = null;
-        else
-            this.defaultCommodity = String.valueOf(defaultCommodity);
-    }
-    public SendTransactionTask.API getApiVersion() {
-        return apiVersion;
-    }
-    public void setApiVersion(SendTransactionTask.API apiVersion) {
-        this.apiVersion = apiVersion;
-    }
-    public void setApiVersion(int apiVersion) {
-        this.apiVersion = SendTransactionTask.API.valueOf(apiVersion);
-    }
-    public FutureDates getFutureDates() {
-        return futureDates;
-    }
-    public void setFutureDates(int anInt) {
-        futureDates = FutureDates.valueOf(anInt);
-    }
-    public void setFutureDates(FutureDates futureDates) {
-        this.futureDates = futureDates;
-    }
-    public String getPreferredAccountsFilter() {
-        return preferredAccountsFilter;
-    }
-    public void setPreferredAccountsFilter(String preferredAccountsFilter) {
-        this.preferredAccountsFilter = preferredAccountsFilter;
-    }
-    public void setPreferredAccountsFilter(CharSequence preferredAccountsFilter) {
-        setPreferredAccountsFilter(String.valueOf(preferredAccountsFilter));
-    }
-    public boolean isPostingPermitted() {
-        return permitPosting;
-    }
-    public void setPostingPermitted(boolean permitPosting) {
-        this.permitPosting = permitPosting;
-    }
-    public String getUuid() {
-        return uuid;
-    }
-    public String getName() {
-        return name;
-    }
-    public void setName(CharSequence text) {
-        setName(String.valueOf(text));
-    }
-    public void setName(String name) {
-        this.name = name;
-    }
-    public String getUrl() {
-        return url;
-    }
-    public void setUrl(CharSequence text) {
-        setUrl(String.valueOf(text));
-    }
-    public void setUrl(String url) {
-        this.url = url;
-    }
-    public boolean isAuthEnabled() {
-        return authEnabled;
-    }
-    public void setAuthEnabled(boolean authEnabled) {
-        this.authEnabled = authEnabled;
-    }
-    public String getAuthUserName() {
-        return authUserName;
-    }
-    public void setAuthUserName(CharSequence text) {
-        setAuthUserName(String.valueOf(text));
-    }
-    public void setAuthUserName(String authUserName) {
-        this.authUserName = authUserName;
-    }
-    public String getAuthPassword() {
-        return authPassword;
-    }
-    public void setAuthPassword(CharSequence text) {
-        setAuthPassword(String.valueOf(text));
-    }
-    public void setAuthPassword(String authPassword) {
-        this.authPassword = authPassword;
-    }
-    public void storeInDB() {
-        SQLiteDatabase db = App.getDatabase();
-        db.beginTransactionNonExclusive();
-        try {
-//            debug("profiles", String.format("Storing profile in DB: uuid=%s, name=%s, " +
-//                                            "url=%s, permit_posting=%s, authEnabled=%s, " +
-//                                            "themeHue=%d", uuid, name, url,
-//                    permitPosting ? "TRUE" : "FALSE", authEnabled ? "TRUE" : "FALSE", themeHue));
-            db.execSQL("REPLACE INTO profiles(uuid, name, permit_posting, url, " +
-                       "use_authentication, auth_user, auth_password, theme, order_no, " +
-                       "preferred_accounts_filter, future_dates, api_version, " +
-                       "show_commodity_by_default, default_commodity, show_comments_by_default," +
-                       "detected_version_pre_1_19, detected_version_major, " +
-                       "detected_version_minor) " +
-                       "VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
-                    new Object[]{uuid, name, permitPosting, url, authEnabled,
-                                 authEnabled ? authUserName : null,
-                                 authEnabled ? authPassword : null, themeHue, orderNo,
-                                 preferredAccountsFilter, futureDates.toInt(), apiVersion.toInt(),
-                                 showCommodityByDefault, defaultCommodity, showCommentsByDefault,
-                                 (detectedVersion != null) && detectedVersion.isPre_1_20(),
-                                 (detectedVersion == null) ? 0 : detectedVersion.getMajor(),
-                                 (detectedVersion == null) ? 0 : detectedVersion.getMinor()
-                    });
-            db.setTransactionSuccessful();
-        }
-        finally {
-            db.endTransaction();
-        }
-    }
-    public void storeAccount(SQLiteDatabase db, int generation, LedgerAccount acc,
-                             boolean storeUiFields) {
-        // replace into is a bad idea because it would reset hidden to its default value
-        // we like the default, but for new accounts only
-        String sql = "update accounts set generation = ?";
-        List<Object> params = new ArrayList<>();
-        params.add(generation);
-        if (storeUiFields) {
-            sql += ", expanded=?";
-            params.add(acc.isExpanded() ? 1 : 0);
-        }
-        sql += " where profile=? and name=?";
-        params.add(uuid);
-        params.add(acc.getName());
-        db.execSQL(sql, params.toArray());
-
-        db.execSQL("insert into accounts(profile, name, name_upper, parent_name, level, " +
-                   "expanded, generation) select ?,?,?,?,?,0,? where (select changes() = 0)",
-                new Object[]{uuid, acc.getName(), acc.getName().toUpperCase(), acc.getParentName(),
-                             acc.getLevel(), generation
-                });
-//        debug("accounts", String.format("Stored account '%s' in DB [%s]", acc.getName(), uuid));
-    }
-    public void storeAccountValue(SQLiteDatabase db, int generation, String name, String currency,
-                                  Float amount) {
-        if (!TextUtils.isEmpty(currency)) {
-            boolean exists;
-            try (Cursor c = db.rawQuery("select 1 from currencies where name=?",
-                    new String[]{currency}))
-            {
-                exists = c.moveToFirst();
-            }
-            if (!exists) {
-                db.execSQL(
-                        "insert into currencies(id, name, position, has_gap) values((select max" +
-                        "(id) from currencies)+1, ?, ?, ?)", new Object[]{currency,
-                                                                          Objects.requireNonNull(
-                                                                                  Data.currencySymbolPosition.getValue()).toString(),
-                                                                          Data.currencyGap.getValue()
-                        });
-            }
-        }
-
-        db.execSQL("replace into account_values(profile, account, " +
-                   "currency, value, generation) values(?, ?, ?, ?, ?);",
-                new Object[]{uuid, name, Misc.emptyIsNull(currency), amount, generation});
-    }
-    public void storeTransaction(SQLiteDatabase db, int generation, LedgerTransaction tr) {
-        tr.fillDataHash();
-//        Logger.debug("storeTransaction", String.format(Locale.US, "ID %d", tr.getId()));
-        SimpleDate d = tr.getDate();
-        db.execSQL("UPDATE transactions SET year=?, month=?, day=?, description=?, comment=?, " +
-                   "data_hash=?, generation=? WHERE profile=? AND id=?",
-                new Object[]{d.year, d.month, d.day, tr.getDescription(), tr.getComment(),
-                             tr.getDataHash(), generation, uuid, tr.getId()
-                });
-        db.execSQL("INSERT INTO transactions(profile, id, year, month, day, description, " +
-                   "comment, data_hash, generation) " +
-                   "select ?,?,?,?,?,?,?,?,? WHERE (select changes() = 0)",
-                new Object[]{uuid, tr.getId(), tr.getDate().year, tr.getDate().month,
-                             tr.getDate().day, tr.getDescription(), tr.getComment(),
-                             tr.getDataHash(), generation
-                });
-
-        int accountOrderNo = 1;
-        for (LedgerTransactionAccount item : tr.getAccounts()) {
-            db.execSQL("UPDATE transaction_accounts SET account_name=?, amount=?, currency=?, " +
-                       "comment=?, generation=? " +
-                       "WHERE profile=? AND transaction_id=? AND order_no=?",
-                    new Object[]{item.getAccountName(), item.getAmount(),
-                                 Misc.nullIsEmpty(item.getCurrency()), item.getComment(),
-                                 generation, uuid, tr.getId(), accountOrderNo
-                    });
-            db.execSQL("INSERT INTO transaction_accounts(profile, transaction_id, " +
-                       "order_no, account_name, amount, currency, comment, generation) " +
-                       "select ?, ?, ?, ?, ?, ?, ?, ? WHERE (select changes() = 0)",
-                    new Object[]{uuid, tr.getId(), accountOrderNo, item.getAccountName(),
-                                 item.getAmount(), Misc.nullIsEmpty(item.getCurrency()),
-                                 item.getComment(), generation
-                    });
-
-            accountOrderNo++;
-        }
-//        debug("profile", String.format("Transaction %d stored", tr.getId()));
-    }
-    public String getOption(String name, String default_value) {
-        SQLiteDatabase db = App.getDatabase();
-        try (Cursor cursor = db.rawQuery("select value from options where profile = ? and name=?",
-                new String[]{uuid, name}))
-        {
-            if (cursor.moveToFirst()) {
-                String result = cursor.getString(0);
-
-                if (result == null) {
-                    debug("profile", "returning default value for " + name);
-                    result = default_value;
-                }
-                else
-                    debug("profile", String.format("option %s=%s", name, result));
-
-                return result;
-            }
-            else
-                return default_value;
-        }
-        catch (Exception e) {
-            debug("db", "returning default value for " + name, e);
-            return default_value;
-        }
-    }
-    public long getLongOption(String name, long default_value) {
-        long longResult;
-        String result = getOption(name, "");
-        if ((result == null) || result.isEmpty()) {
-            debug("profile", String.format("Returning default value for option %s", name));
-            longResult = default_value;
-        }
-        else {
-            try {
-                longResult = Long.parseLong(result);
-                debug("profile", String.format("option %s=%s", name, result));
-            }
-            catch (Exception e) {
-                debug("profile", String.format("Returning default value for option %s", name), e);
-                longResult = default_value;
-            }
-        }
-
-        return longResult;
-    }
-    public void setOption(String name, String value) {
-        debug("profile", String.format("setting option %s=%s", name, value));
-        DbOpQueue.add("insert or replace into options(profile, name, value) values(?, ?, ?);",
-                new String[]{uuid, name, value});
-    }
-    public void setLongOption(String name, long value) {
-        setOption(name, String.valueOf(value));
-    }
-    public void removeFromDB() {
-        SQLiteDatabase db = App.getDatabase();
-        debug("db", String.format("removing profile %s from DB", uuid));
-        db.beginTransactionNonExclusive();
-        try {
-            Object[] uuid_param = new Object[]{uuid};
-            db.execSQL("delete from transaction_accounts where profile=?", uuid_param);
-            db.execSQL("delete from transactions where profile=?", uuid_param);
-            db.execSQL("delete from account_values where profile=?", uuid_param);
-            db.execSQL("delete from accounts where profile=?", uuid_param);
-            db.execSQL("delete from options where profile=?", uuid_param);
-            db.execSQL("delete from profiles where uuid=?", uuid_param);
-            db.setTransactionSuccessful();
-        }
-        finally {
-            db.endTransaction();
-        }
-    }
-    public LedgerTransaction loadTransaction(int transactionId) {
-        LedgerTransaction tr = new LedgerTransaction(transactionId, this.uuid);
-        tr.loadData(App.getDatabase());
-
-        return tr;
-    }
-    public int getThemeHue() {
-//        debug("profile", String.format("Profile.getThemeHue() returning %d", themeHue));
-        return this.themeHue;
-    }
-    public void setThemeHue(Object o) {
-        setThemeId(Integer.parseInt(String.valueOf(o)));
-    }
-    public void setThemeId(int themeHue) {
-//        debug("profile", String.format("Profile.setThemeHue(%d) called", themeHue));
-        this.themeHue = themeHue;
-    }
-    public int getNextTransactionsGeneration(SQLiteDatabase db) {
-        int generation = 1;
-        try (Cursor c = db.rawQuery("SELECT generation FROM transactions WHERE profile=? LIMIT 1",
-                new String[]{uuid}))
-        {
-            if (c.moveToFirst()) {
-                generation = c.getInt(0) + 1;
-            }
-        }
-        return generation;
-    }
-    private int getNextAccountsGeneration(SQLiteDatabase db) {
-        int generation = 1;
-        try (Cursor c = db.rawQuery("SELECT generation FROM accounts WHERE profile=? LIMIT 1",
-                new String[]{uuid}))
-        {
-            if (c.moveToFirst()) {
-                generation = c.getInt(0) + 1;
-            }
-        }
-        return generation;
-    }
-    private void deleteNotPresentAccounts(SQLiteDatabase db, int generation) {
-        Logger.debug("db/benchmark", "Deleting obsolete accounts");
-        db.execSQL("DELETE FROM account_values WHERE profile=? AND generation <> ?",
-                new Object[]{uuid, generation});
-        db.execSQL("DELETE FROM accounts WHERE profile=? AND generation <> ?",
-                new Object[]{uuid, generation});
-        Logger.debug("db/benchmark", "Done deleting obsolete accounts");
-    }
-    private void deleteNotPresentTransactions(SQLiteDatabase db, int generation) {
-        Logger.debug("db/benchmark", "Deleting obsolete transactions");
-        db.execSQL("DELETE FROM transaction_accounts WHERE profile=? AND generation <> ?",
-                new Object[]{uuid, generation});
-        db.execSQL("DELETE FROM transactions WHERE profile=? AND generation <> ?",
-                new Object[]{uuid, generation});
-        Logger.debug("db/benchmark", "Done deleting obsolete transactions");
-    }
-    public void wipeAllData() {
-        SQLiteDatabase db = App.getDatabase();
-        db.beginTransaction();
-        try {
-            String[] pUuid = new String[]{uuid};
-            db.execSQL("delete from options where profile=?", pUuid);
-            db.execSQL("delete from accounts where profile=?", pUuid);
-            db.execSQL("delete from account_values where profile=?", pUuid);
-            db.execSQL("delete from transactions where profile=?", pUuid);
-            db.execSQL("delete from transaction_accounts where profile=?", pUuid);
-            db.setTransactionSuccessful();
-            debug("wipe", String.format(Locale.ENGLISH, "Profile %s wiped out", pUuid[0]));
-        }
-        finally {
-            db.endTransaction();
-        }
-    }
-    public List<Currency> getCurrencies() {
-        SQLiteDatabase db = App.getDatabase();
-
-        ArrayList<Currency> result = new ArrayList<>();
-
-        try (Cursor c = db.rawQuery("SELECT c.id, c.name, c.position, c.has_gap FROM currencies c",
-                new String[]{}))
-        {
-            while (c.moveToNext()) {
-                Currency currency = new Currency(c.getInt(0), c.getString(1),
-                        Currency.Position.valueOf(c.getString(2)), c.getInt(3) == 1);
-                result.add(currency);
-            }
-        }
-
-        return result;
-    }
-    Currency loadCurrencyByName(String name) {
-        SQLiteDatabase db = App.getDatabase();
-        Currency result = tryLoadCurrencyByName(db, name);
-        if (result == null)
-            throw new RuntimeException(String.format("Unable to load currency '%s'", name));
-        return result;
-    }
-    private Currency tryLoadCurrencyByName(SQLiteDatabase db, String name) {
-        try (Cursor cursor = db.rawQuery(
-                "SELECT c.id, c.name, c.position, c.has_gap FROM currencies c WHERE c.name=?",
-                new String[]{name}))
-        {
-            if (cursor.moveToFirst()) {
-                return new Currency(cursor.getInt(0), cursor.getString(1),
-                        Currency.Position.valueOf(cursor.getString(2)), cursor.getInt(3) == 1);
-            }
-            return null;
-        }
-    }
-    public void storeAccountAndTransactionListAsync(List<LedgerAccount> accounts,
-                                                    List<LedgerTransaction> transactions) {
-        if (accountAndTransactionListSaver != null)
-            accountAndTransactionListSaver.interrupt();
-
-        accountAndTransactionListSaver =
-                new AccountAndTransactionListSaver(this, accounts, transactions);
-        accountAndTransactionListSaver.start();
-    }
-
-    public enum FutureDates {
-        None(0), OneWeek(7), TwoWeeks(14), OneMonth(30), TwoMonths(60), ThreeMonths(90),
-        SixMonths(180), OneYear(365), All(-1);
-        private static final SparseArray<FutureDates> map = new SparseArray<>();
-
-        static {
-            for (FutureDates item : FutureDates.values()) {
-                map.put(item.value, item);
-            }
-        }
-
-        private int value;
-        FutureDates(int value) {
-            this.value = value;
-        }
-        public static FutureDates valueOf(int i) {
-            return map.get(i, None);
-        }
-        public int toInt() {
-            return this.value;
-        }
-        public String getText(Resources resources) {
-            switch (value) {
-                case 7:
-                    return resources.getString(R.string.future_dates_7);
-                case 14:
-                    return resources.getString(R.string.future_dates_14);
-                case 30:
-                    return resources.getString(R.string.future_dates_30);
-                case 60:
-                    return resources.getString(R.string.future_dates_60);
-                case 90:
-                    return resources.getString(R.string.future_dates_90);
-                case 180:
-                    return resources.getString(R.string.future_dates_180);
-                case 365:
-                    return resources.getString(R.string.future_dates_365);
-                case -1:
-                    return resources.getString(R.string.future_dates_all);
-                default:
-                    return resources.getString(R.string.future_dates_none);
-            }
-        }
-    }
-
-    private static class AccountAndTransactionListSaver extends Thread {
-        private final MobileLedgerProfile profile;
-        private final List<LedgerAccount> accounts;
-        private final List<LedgerTransaction> transactions;
-        AccountAndTransactionListSaver(MobileLedgerProfile profile, List<LedgerAccount> accounts,
-                                       List<LedgerTransaction> transactions) {
-            this.accounts = accounts;
-            this.transactions = transactions;
-            this.profile = profile;
-        }
-        public int getNextDescriptionsGeneration(SQLiteDatabase db) {
-            int generation = 1;
-            try (Cursor c = db.rawQuery("SELECT generation FROM description_history LIMIT 1",
-                    null))
-            {
-                if (c.moveToFirst()) {
-                    generation = c.getInt(0) + 1;
-                }
-            }
-            return generation;
-        }
-        void deleteNotPresentDescriptions(SQLiteDatabase db, int generation) {
-            Logger.debug("db/benchmark", "Deleting obsolete descriptions");
-            db.execSQL("DELETE FROM description_history WHERE generation <> ?",
-                    new Object[]{generation});
-            db.execSQL("DELETE FROM description_history WHERE generation <> ?",
-                    new Object[]{generation});
-            Logger.debug("db/benchmark", "Done deleting obsolete descriptions");
-        }
-        @Override
-        public void run() {
-            SQLiteDatabase db = App.getDatabase();
-            db.beginTransactionNonExclusive();
-            try {
-                int accountsGeneration = profile.getNextAccountsGeneration(db);
-                if (isInterrupted())
-                    return;
-
-                int transactionsGeneration = profile.getNextTransactionsGeneration(db);
-                if (isInterrupted())
-                    return;
-
-                for (LedgerAccount acc : accounts) {
-                    profile.storeAccount(db, accountsGeneration, acc, false);
-                    if (isInterrupted())
-                        return;
-                    for (LedgerAmount amt : acc.getAmounts()) {
-                        profile.storeAccountValue(db, accountsGeneration, acc.getName(),
-                                amt.getCurrency(), amt.getAmount());
-                        if (isInterrupted())
-                            return;
-                    }
-                }
-
-                for (LedgerTransaction tr : transactions) {
-                    profile.storeTransaction(db, transactionsGeneration, tr);
-                    if (isInterrupted())
-                        return;
-                }
-
-                profile.deleteNotPresentTransactions(db, transactionsGeneration);
-                if (isInterrupted()) {
-                    return;
-                }
-                profile.deleteNotPresentAccounts(db, accountsGeneration);
-                if (isInterrupted())
-                    return;
-
-                Map<String, Boolean> unique = new HashMap<>();
-
-                debug("descriptions", "Starting refresh");
-                int descriptionsGeneration = getNextDescriptionsGeneration(db);
-                try (Cursor c = db.rawQuery("SELECT distinct description from transactions",
-                        null))
-                {
-                    while (c.moveToNext()) {
-                        String description = c.getString(0);
-                        String descriptionUpper = description.toUpperCase();
-                        if (unique.containsKey(descriptionUpper))
-                            continue;
-
-                        storeDescription(db, descriptionsGeneration, description, descriptionUpper);
-
-                        unique.put(descriptionUpper, true);
-                    }
-                }
-                deleteNotPresentDescriptions(db, descriptionsGeneration);
-
-                db.setTransactionSuccessful();
-            }
-            finally {
-                db.endTransaction();
-            }
-        }
-        private void storeDescription(SQLiteDatabase db, int generation, String description,
-                                      String descriptionUpper) {
-            db.execSQL("UPDATE description_history SET description=?, generation=? WHERE " +
-                       "description_upper=?", new Object[]{description, generation, descriptionUpper
-            });
-            db.execSQL(
-                    "INSERT INTO description_history(description, description_upper, generation) " +
-                    "select ?,?,? WHERE (select changes() = 0)",
-                    new Object[]{description, descriptionUpper, generation
-                    });
-        }
-    }
-}
diff --git a/app/src/main/java/net/ktnx/mobileledger/model/TemplateDetailSource.java b/app/src/main/java/net/ktnx/mobileledger/model/TemplateDetailSource.java
new file mode 100644 (file)
index 0000000..611bcfc
--- /dev/null
@@ -0,0 +1,60 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.model;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.DiffUtil;
+
+import java.io.Serializable;
+
+public class TemplateDetailSource implements Serializable {
+    public static final DiffUtil.ItemCallback<TemplateDetailSource> DIFF_CALLBACK =
+            new DiffUtil.ItemCallback<TemplateDetailSource>() {
+                @Override
+                public boolean areItemsTheSame(@NonNull TemplateDetailSource oldItem,
+                                               @NonNull TemplateDetailSource newItem) {
+                    return oldItem.groupNumber == newItem.groupNumber;
+                }
+                @Override
+                public boolean areContentsTheSame(@NonNull TemplateDetailSource oldItem,
+                                                  @NonNull TemplateDetailSource newItem) {
+                    return oldItem.matchedText.equals(newItem.matchedText);
+                }
+            };
+
+    private short groupNumber;
+    private String matchedText;
+    public TemplateDetailSource() {
+    }
+    public TemplateDetailSource(short groupNumber, String matchedText) {
+        this.groupNumber = groupNumber;
+        this.matchedText = matchedText;
+    }
+    public short getGroupNumber() {
+        return groupNumber;
+    }
+    public void setGroupNumber(short groupNumber) {
+        this.groupNumber = groupNumber;
+    }
+    public String getMatchedText() {
+        return matchedText;
+    }
+    public void setMatchedText(String matchedText) {
+        this.matchedText = matchedText;
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/model/TemplateDetailsItem.java b/app/src/main/java/net/ktnx/mobileledger/model/TemplateDetailsItem.java
new file mode 100644 (file)
index 0000000..9a8f04c
--- /dev/null
@@ -0,0 +1,758 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.model;
+
+import android.content.res.Resources;
+import android.graphics.Color;
+import android.graphics.Typeface;
+import android.text.SpannableString;
+import android.text.Spanned;
+import android.text.style.ForegroundColorSpan;
+import android.text.style.StyleSpan;
+import android.text.style.UnderlineSpan;
+
+import androidx.annotation.NonNull;
+
+import net.ktnx.mobileledger.R;
+import net.ktnx.mobileledger.db.TemplateAccount;
+import net.ktnx.mobileledger.db.TemplateBase;
+import net.ktnx.mobileledger.db.TemplateHeader;
+import net.ktnx.mobileledger.utils.Logger;
+import net.ktnx.mobileledger.utils.Misc;
+
+import org.jetbrains.annotations.Contract;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Locale;
+import java.util.Objects;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+
+abstract public class TemplateDetailsItem {
+    private final Type type;
+    protected Long id;
+    protected long position;
+
+    protected TemplateDetailsItem(Type type) {
+        this.type = type;
+    }
+    @Contract(" -> new")
+    public static @NotNull TemplateDetailsItem.Header createHeader() {
+        return new Header();
+    }
+    public static @NotNull TemplateDetailsItem.Header createHeader(Header origin) {
+        return new Header(origin);
+    }
+    @Contract("-> new")
+    public static @NotNull TemplateDetailsItem.AccountRow createAccountRow() {
+        return new AccountRow();
+    }
+    public static TemplateDetailsItem fromRoomObject(TemplateBase p) {
+        if (p instanceof TemplateHeader) {
+            TemplateHeader ph = (TemplateHeader) p;
+            Header header = createHeader();
+            header.setId(ph.getId());
+            header.setName(ph.getName());
+            header.setPattern(ph.getRegularExpression());
+            header.setTestText(ph.getTestText());
+
+            if (ph.getTransactionDescriptionMatchGroup() == null)
+                header.setTransactionDescription(ph.getTransactionDescription());
+            else
+                header.setTransactionDescriptionMatchGroup(
+                        ph.getTransactionDescriptionMatchGroup());
+
+            if (ph.getTransactionCommentMatchGroup() == null)
+                header.setTransactionComment(ph.getTransactionComment());
+            else
+                header.setTransactionCommentMatchGroup(ph.getTransactionCommentMatchGroup());
+
+            if (ph.getDateDayMatchGroup() == null)
+                header.setDateDay(ph.getDateDay());
+            else
+                header.setDateDayMatchGroup(ph.getDateDayMatchGroup());
+
+            if (ph.getDateMonthMatchGroup() == null)
+                header.setDateMonth(ph.getDateMonth());
+            else
+                header.setDateMonthMatchGroup(ph.getDateMonthMatchGroup());
+
+            if (ph.getDateYearMatchGroup() == null)
+                header.setDateYear(ph.getDateYear());
+            else
+                header.setDateYearMatchGroup(ph.getDateYearMatchGroup());
+
+            header.setFallback(ph.isFallback());
+
+            return header;
+        }
+        else if (p instanceof TemplateAccount) {
+            TemplateAccount pa = (TemplateAccount) p;
+            AccountRow acc = createAccountRow();
+            acc.setId(pa.getId());
+            acc.setPosition(pa.getPosition());
+
+            if (pa.getAccountNameMatchGroup() == null)
+                acc.setAccountName(Misc.nullIsEmpty(pa.getAccountName()));
+            else
+                acc.setAccountNameMatchGroup(pa.getAccountNameMatchGroup());
+
+            if (pa.getAccountCommentMatchGroup() == null)
+                acc.setAccountComment(Misc.nullIsEmpty(pa.getAccountComment()));
+            else
+                acc.setAccountCommentMatchGroup(pa.getAccountCommentMatchGroup());
+
+            if (pa.getCurrencyMatchGroup() == null) {
+                acc.setCurrency(pa.getCurrencyObject());
+            }
+            else
+                acc.setCurrencyMatchGroup(pa.getCurrencyMatchGroup());
+
+            final Integer amountMatchGroup = pa.getAmountMatchGroup();
+            if (amountMatchGroup != null && amountMatchGroup > 0) {
+                acc.setAmountMatchGroup(amountMatchGroup);
+                final Boolean negateAmount = pa.getNegateAmount();
+                acc.setNegateAmount(negateAmount != null && negateAmount);
+            }
+            else
+                acc.setAmount(pa.getAmount());
+
+            return acc;
+        }
+        else {
+            throw new IllegalStateException("Unexpected item class " + p.getClass());
+        }
+    }
+    public Header asHeaderItem() {
+        ensureType(Type.HEADER);
+        return (Header) this;
+    }
+    public AccountRow asAccountRowItem() {
+        ensureType(Type.ACCOUNT_ITEM);
+        return (AccountRow) this;
+    }
+    private void ensureType(Type type) {
+        if (this.type != type)
+            throw new IllegalStateException(
+                    String.format("Type is %s, but %s is required", this.type.toString(),
+                            type.toString()));
+    }
+    void ensureTrue(boolean flag) {
+        if (!flag)
+            throw new IllegalStateException(
+                    "Literal value requested, but it is matched via a pattern group");
+    }
+    void ensureFalse(boolean flag) {
+        if (flag)
+            throw new IllegalStateException("Matching group requested, but the value is a literal");
+    }
+    public long getId() {
+        return id;
+    }
+    public void setId(Long id) {
+        this.id = id;
+    }
+    public void setId(int id) {
+        this.id = (long) id;
+    }
+    public long getPosition() {
+        return position;
+    }
+    public void setPosition(long position) {
+        this.position = position;
+    }
+    abstract public String getProblem(@NonNull Resources r, int patternGroupCount);
+    public Type getType() {
+        return type;
+    }
+    public enum Type {
+        HEADER(TYPE.header), ACCOUNT_ITEM(TYPE.accountItem);
+        final int index;
+        Type(int i) {
+            index = i;
+        }
+        public int toInt() {
+            return index;
+        }
+    }
+
+    static class PossiblyMatchedValue<T> {
+        private boolean literalValue;
+        private T value;
+        private int matchGroup;
+        public PossiblyMatchedValue() {
+            literalValue = true;
+            value = null;
+        }
+        public PossiblyMatchedValue(@NonNull PossiblyMatchedValue<T> origin) {
+            literalValue = origin.literalValue;
+            value = origin.value;
+            matchGroup = origin.matchGroup;
+        }
+        @NonNull
+        public static PossiblyMatchedValue<Integer> withLiteralInt(Integer initialValue) {
+            PossiblyMatchedValue<Integer> result = new PossiblyMatchedValue<>();
+            result.setValue(initialValue);
+            return result;
+        }
+        @NonNull
+        public static PossiblyMatchedValue<Float> withLiteralFloat(Float initialValue) {
+            PossiblyMatchedValue<Float> result = new PossiblyMatchedValue<>();
+            result.setValue(initialValue);
+            return result;
+        }
+        public static PossiblyMatchedValue<Short> withLiteralShort(Short initialValue) {
+            PossiblyMatchedValue<Short> result = new PossiblyMatchedValue<>();
+            result.setValue(initialValue);
+            return result;
+        }
+        @NonNull
+        public static PossiblyMatchedValue<String> withLiteralString(String initialValue) {
+            PossiblyMatchedValue<String> result = new PossiblyMatchedValue<>();
+            result.setValue(initialValue);
+            return result;
+        }
+        public void copyFrom(@NonNull PossiblyMatchedValue<T> origin) {
+            literalValue = origin.literalValue;
+            value = origin.value;
+            matchGroup = origin.matchGroup;
+        }
+        public T getValue() {
+            if (!literalValue)
+                throw new IllegalStateException("Value is not literal");
+            return value;
+        }
+        public void setValue(T newValue) {
+            value = newValue;
+            literalValue = true;
+        }
+        public boolean hasLiteralValue() {
+            return literalValue;
+        }
+        public int getMatchGroup() {
+            if (literalValue)
+                throw new IllegalStateException("Value is literal");
+            return matchGroup;
+        }
+        public void setMatchGroup(int group) {
+            this.matchGroup = group;
+            literalValue = false;
+        }
+        public boolean equals(PossiblyMatchedValue<T> other) {
+            if (!other.literalValue == literalValue)
+                return false;
+            if (literalValue) {
+                if (value == null)
+                    return other.value == null;
+                return value.equals(other.value);
+            }
+            else
+                return matchGroup == other.matchGroup;
+        }
+        public void switchToLiteral() {
+            literalValue = true;
+        }
+        public String toString() {
+            if (literalValue)
+                if (value == null)
+                    return "<null>";
+                else
+                    return value.toString();
+            if (matchGroup > 0)
+                return "grp:" + matchGroup;
+            return "<null>";
+        }
+        public boolean isEmpty() {
+            if (literalValue)
+                return value == null || Misc.emptyIsNull(value.toString()) == null;
+
+            return matchGroup > 0;
+        }
+    }
+
+    public static class TYPE {
+        public static final int header = 0;
+        public static final int accountItem = 1;
+    }
+
+    public static class AccountRow extends TemplateDetailsItem {
+        private final PossiblyMatchedValue<String> accountName =
+                PossiblyMatchedValue.withLiteralString("");
+        private final PossiblyMatchedValue<String> accountComment =
+                PossiblyMatchedValue.withLiteralString("");
+        private final PossiblyMatchedValue<Float> amount =
+                PossiblyMatchedValue.withLiteralFloat(null);
+        private final PossiblyMatchedValue<net.ktnx.mobileledger.db.Currency> currency =
+                new PossiblyMatchedValue<>();
+        private boolean negateAmount;
+        public AccountRow() {
+            super(Type.ACCOUNT_ITEM);
+        }
+        public AccountRow(AccountRow origin) {
+            super(Type.ACCOUNT_ITEM);
+            id = origin.id;
+            position = origin.position;
+            accountName.copyFrom(origin.accountName);
+            accountComment.copyFrom(origin.accountComment);
+            amount.copyFrom(origin.amount);
+            currency.copyFrom(origin.currency);
+            negateAmount = origin.negateAmount;
+        }
+        public boolean isNegateAmount() {
+            return negateAmount;
+        }
+        public void setNegateAmount(boolean negateAmount) {
+            this.negateAmount = negateAmount;
+        }
+        public int getAccountCommentMatchGroup() {
+            return accountComment.getMatchGroup();
+        }
+        public void setAccountCommentMatchGroup(int group) {
+            accountComment.setMatchGroup(group);
+        }
+        public String getAccountComment() {
+            return accountComment.getValue();
+        }
+        public void setAccountComment(String comment) {
+            this.accountComment.setValue(comment);
+        }
+        public int getCurrencyMatchGroup() {
+            return currency.getMatchGroup();
+        }
+        public void setCurrencyMatchGroup(int group) {
+            currency.setMatchGroup(group);
+        }
+        public net.ktnx.mobileledger.db.Currency getCurrency() {
+            return currency.getValue();
+        }
+        public void setCurrency(net.ktnx.mobileledger.db.Currency currency) {
+            this.currency.setValue(currency);
+        }
+        public int getAccountNameMatchGroup() {
+            return accountName.getMatchGroup();
+        }
+        public void setAccountNameMatchGroup(int group) {
+            accountName.setMatchGroup(group);
+        }
+        public String getAccountName() {
+            return accountName.getValue();
+        }
+        public void setAccountName(String accountName) {
+            this.accountName.setValue(accountName);
+        }
+        public boolean hasLiteralAccountName() { return accountName.hasLiteralValue(); }
+        public boolean hasLiteralAmount() {
+            return amount.hasLiteralValue();
+        }
+        public int getAmountMatchGroup() {
+            return amount.getMatchGroup();
+        }
+        public void setAmountMatchGroup(int group) {
+            amount.setMatchGroup(group);
+        }
+        public Float getAmount() {
+            return amount.getValue();
+        }
+        public void setAmount(Float amount) {
+            this.amount.setValue(amount);
+        }
+        public String getProblem(@NonNull Resources r, int patternGroupCount) {
+            if (Misc.emptyIsNull(accountName.getValue()) == null)
+                return r.getString(R.string.account_name_is_empty);
+            if (!amount.hasLiteralValue() &&
+                (amount.getMatchGroup() < 1 || amount.getMatchGroup() > patternGroupCount))
+                return r.getString(R.string.invalid_matching_group_number);
+
+            return null;
+        }
+        public boolean hasLiteralAccountComment() {
+            return accountComment.hasLiteralValue();
+        }
+        public boolean hasLiteralCurrency() { return currency.hasLiteralValue(); }
+        public boolean equalContents(AccountRow o) {
+            if (position != o.position) {
+                Logger.debug("cmpAcc",
+                        String.format(Locale.US, "[%d] != [%d]: pos %d != pos %d", getId(),
+                                o.getId(), position, o.position));
+                return false;
+            }
+            return amount.equals(o.amount) && accountName.equals(o.accountName) &&
+                   position == o.position && accountComment.equals(o.accountComment) &&
+                   negateAmount == o.negateAmount;
+        }
+        public void switchToLiteralAmount() {
+            amount.switchToLiteral();
+        }
+        public void switchToLiteralCurrency() {
+            currency.switchToLiteral();
+        }
+        public void switchToLiteralAccountName() {
+            accountName.switchToLiteral();
+        }
+        public void switchToLiteralAccountComment() {
+            accountComment.switchToLiteral();
+        }
+        public TemplateAccount toDBO(@NonNull Long patternId) {
+            TemplateAccount result = new TemplateAccount(id, patternId, position);
+
+            if (accountName.hasLiteralValue())
+                result.setAccountName(accountName.getValue());
+            else
+                result.setAccountNameMatchGroup(accountName.getMatchGroup());
+
+            if (accountComment.hasLiteralValue())
+                result.setAccountComment(accountComment.getValue());
+            else
+                result.setAccountCommentMatchGroup(accountComment.getMatchGroup());
+
+            if (amount.hasLiteralValue()) {
+                result.setAmount(amount.getValue());
+                result.setNegateAmount(null);
+            }
+            else {
+                result.setAmountMatchGroup(amount.getMatchGroup());
+                result.setNegateAmount(negateAmount ? true : null);
+            }
+
+            if (currency.hasLiteralValue()) {
+                net.ktnx.mobileledger.db.Currency c = currency.getValue();
+                result.setCurrency((c == null) ? null : c.getId());
+            }
+            else {
+                result.setCurrencyMatchGroup(currency.getMatchGroup());
+            }
+
+            return result;
+        }
+        public boolean isEmpty() {
+            return accountName.isEmpty() && accountComment.isEmpty() && amount.isEmpty();
+        }
+    }
+
+    public static class Header extends TemplateDetailsItem {
+        private String pattern = "";
+        private String testText = "";
+        private String name = "";
+        private Pattern compiledPattern;
+        private String patternError;
+        private PossiblyMatchedValue<String> transactionDescription =
+                PossiblyMatchedValue.withLiteralString("");
+        private PossiblyMatchedValue<String> transactionComment =
+                PossiblyMatchedValue.withLiteralString("");
+        private PossiblyMatchedValue<Integer> dateYear = PossiblyMatchedValue.withLiteralInt(null);
+        private PossiblyMatchedValue<Integer> dateMonth = PossiblyMatchedValue.withLiteralInt(null);
+        private PossiblyMatchedValue<Integer> dateDay = PossiblyMatchedValue.withLiteralInt(null);
+        private SpannableString testMatch;
+        private boolean isFallback;
+        private Header() {
+            super(Type.HEADER);
+        }
+        public Header(Header origin) {
+            this();
+            id = origin.id;
+            name = origin.name;
+            testText = origin.testText;
+            testMatch = origin.testMatch;
+            setPattern(origin.pattern);
+
+            transactionDescription = new PossiblyMatchedValue<>(origin.transactionDescription);
+            transactionComment = new PossiblyMatchedValue<>(origin.transactionComment);
+
+            dateYear = new PossiblyMatchedValue<>(origin.dateYear);
+            dateMonth = new PossiblyMatchedValue<>(origin.dateMonth);
+            dateDay = new PossiblyMatchedValue<>(origin.dateDay);
+
+            isFallback = origin.isFallback;
+        }
+        private static StyleSpan capturedSpan() { return new StyleSpan(Typeface.BOLD); }
+        private static UnderlineSpan matchedSpan() { return new UnderlineSpan(); }
+        private static ForegroundColorSpan notMatchedSpan() {
+            return new ForegroundColorSpan(Color.GRAY);
+        }
+        public boolean isFallback() {
+            return isFallback;
+        }
+        public void setFallback(boolean fallback) {
+            this.isFallback = fallback;
+        }
+        public String getName() {
+            return name;
+        }
+        public void setName(String name) {
+            this.name = name;
+        }
+        public String getPattern() {
+            return pattern;
+        }
+        public void setPattern(String pattern) {
+            this.pattern = pattern;
+            try {
+                this.compiledPattern = Pattern.compile(pattern);
+                checkPatternMatch();
+            }
+            catch (PatternSyntaxException ex) {
+                patternError = ex.getDescription();
+                compiledPattern = null;
+
+                testMatch = new SpannableString(testText);
+                if (!testText.isEmpty())
+                    testMatch.setSpan(notMatchedSpan(), 0, testText.length() - 1,
+                            Spanned.SPAN_INCLUSIVE_INCLUSIVE);
+            }
+        }
+        @NonNull
+        @Override
+        public String toString() {
+            return super.toString() +
+                   String.format(" name[%s] pat[%s] test[%s] tran[%s] com[%s]", name, pattern,
+                           testText, transactionDescription, transactionComment);
+        }
+        public String getTestText() {
+            return testText;
+        }
+        public void setTestText(String testText) {
+            this.testText = testText;
+
+            checkPatternMatch();
+        }
+        public String getTransactionDescription() {
+            return transactionDescription.getValue();
+        }
+        public void setTransactionDescription(String transactionDescription) {
+            this.transactionDescription.setValue(transactionDescription);
+        }
+        public String getTransactionComment() {
+            return transactionComment.getValue();
+        }
+        public void setTransactionComment(String transactionComment) {
+            this.transactionComment.setValue(transactionComment);
+        }
+        public Integer getDateYear() {
+            return dateYear.getValue();
+        }
+        public void setDateYear(Integer dateYear) {
+            this.dateYear.setValue(dateYear);
+        }
+        public Integer getDateMonth() {
+            return dateMonth.getValue();
+        }
+        public void setDateMonth(Integer dateMonth) {
+            this.dateMonth.setValue(dateMonth);
+        }
+        public Integer getDateDay() {
+            return dateDay.getValue();
+        }
+        public void setDateDay(Integer dateDay) {
+            this.dateDay.setValue(dateDay);
+        }
+        public int getDateYearMatchGroup() {
+            return dateYear.getMatchGroup();
+        }
+        public void setDateYearMatchGroup(int dateYearMatchGroup) {
+            this.dateYear.setMatchGroup(dateYearMatchGroup);
+        }
+        public int getDateMonthMatchGroup() {
+            return dateMonth.getMatchGroup();
+        }
+        public void setDateMonthMatchGroup(int dateMonthMatchGroup) {
+            this.dateMonth.setMatchGroup(dateMonthMatchGroup);
+        }
+        public int getDateDayMatchGroup() {
+            return dateDay.getMatchGroup();
+        }
+        public void setDateDayMatchGroup(int dateDayMatchGroup) {
+            this.dateDay.setMatchGroup(dateDayMatchGroup);
+        }
+        public boolean hasLiteralDateYear() {
+            return dateYear.hasLiteralValue();
+        }
+        public boolean hasLiteralDateMonth() {
+            return dateMonth.hasLiteralValue();
+        }
+        public boolean hasLiteralDateDay() {
+            return dateDay.hasLiteralValue();
+        }
+        public boolean hasLiteralTransactionDescription() { return transactionDescription.hasLiteralValue(); }
+        public boolean hasLiteralTransactionComment() { return transactionComment.hasLiteralValue(); }
+        public String getProblem(@NonNull Resources r, int patternGroupCount) {
+            if (patternError != null)
+                return r.getString(R.string.pattern_has_errors) + ": " + patternError;
+            if (Misc.emptyIsNull(pattern) == null)
+                return r.getString(R.string.pattern_is_empty);
+
+            if (!dateYear.hasLiteralValue() && compiledPattern != null &&
+                (dateDay.getMatchGroup() < 1 || dateDay.getMatchGroup() > patternGroupCount))
+                return r.getString(R.string.invalid_matching_group_number);
+
+            if (!dateMonth.hasLiteralValue() && compiledPattern != null &&
+                (dateMonth.getMatchGroup() < 1 || dateMonth.getMatchGroup() > patternGroupCount))
+                return r.getString(R.string.invalid_matching_group_number);
+
+            if (!dateDay.hasLiteralValue() && compiledPattern != null &&
+                (dateDay.getMatchGroup() < 1 || dateDay.getMatchGroup() > patternGroupCount))
+                return r.getString(R.string.invalid_matching_group_number);
+
+            return null;
+        }
+
+        public boolean equalContents(Header o) {
+            if (!dateDay.equals(o.dateDay))
+                return false;
+            if (!dateMonth.equals(o.dateMonth))
+                return false;
+            if (!dateYear.equals(o.dateYear))
+                return false;
+            if (!transactionDescription.equals(o.transactionDescription))
+                return false;
+            if (!transactionComment.equals(o.transactionComment))
+                return true;
+
+            return Misc.equalStrings(name, o.name) && Misc.equalStrings(pattern, o.pattern) &&
+                   Misc.equalStrings(testText, o.testText) &&
+                   Misc.equalStrings(patternError, o.patternError) &&
+                   Objects.equals(testMatch, o.testMatch) && isFallback == o.isFallback;
+        }
+        public String getMatchGroupText(int group) {
+            if (compiledPattern != null && testText != null) {
+                Matcher m = compiledPattern.matcher(testText);
+                if (m.matches())
+                    return m.group(group);
+            }
+
+            return "ø";
+        }
+        public Pattern getCompiledPattern() {
+            return compiledPattern;
+        }
+        public void switchToLiteralTransactionDescription() {
+            transactionDescription.switchToLiteral();
+        }
+        public void switchToLiteralTransactionComment() {
+            transactionComment.switchToLiteral();
+        }
+        public int getTransactionDescriptionMatchGroup() {
+            return transactionDescription.getMatchGroup();
+        }
+        public void setTransactionDescriptionMatchGroup(int group) {
+            transactionDescription.setMatchGroup(group);
+        }
+        public int getTransactionCommentMatchGroup() {
+            return transactionComment.getMatchGroup();
+        }
+        public void setTransactionCommentMatchGroup(int group) {
+            transactionComment.setMatchGroup(group);
+        }
+        public void switchToLiteralDateYear() {
+            dateYear.switchToLiteral();
+        }
+        public void switchToLiteralDateMonth() {
+            dateMonth.switchToLiteral();
+        }
+        public void switchToLiteralDateDay() { dateDay.switchToLiteral(); }
+        public TemplateHeader toDBO() {
+            TemplateHeader result = new TemplateHeader(id, name, pattern);
+
+            if (Misc.emptyIsNull(testText) != null)
+                result.setTestText(testText);
+
+            if (transactionDescription.hasLiteralValue())
+                result.setTransactionDescription(transactionDescription.getValue());
+            else
+                result.setTransactionDescriptionMatchGroup(transactionDescription.getMatchGroup());
+
+            if (transactionComment.hasLiteralValue())
+                result.setTransactionComment(transactionComment.getValue());
+            else
+                result.setTransactionCommentMatchGroup(transactionComment.getMatchGroup());
+
+            if (dateYear.hasLiteralValue())
+                result.setDateYear(dateYear.getValue());
+            else
+                result.setDateYearMatchGroup(dateYear.getMatchGroup());
+
+            if (dateMonth.hasLiteralValue())
+                result.setDateMonth(dateMonth.getValue());
+            else
+                result.setDateMonthMatchGroup(dateMonth.getMatchGroup());
+
+            if (dateDay.hasLiteralValue())
+                result.setDateDay(dateDay.getValue());
+            else
+                result.setDateDayMatchGroup(dateDay.getMatchGroup());
+
+            result.setFallback(isFallback);
+
+            return result;
+        }
+        public SpannableString getTestMatch() {
+            return testMatch;
+        }
+        public void checkPatternMatch() {
+            patternError = null;
+            testMatch = null;
+
+            if (pattern != null) {
+                try {
+                    if (Misc.emptyIsNull(testText) != null) {
+                        SpannableString ss = new SpannableString(testText);
+                        Matcher m = compiledPattern.matcher(testText);
+                        if (m.find()) {
+                            if (m.start() > 0)
+                                ss.setSpan(notMatchedSpan(), 0, m.start(),
+                                        Spanned.SPAN_INCLUSIVE_INCLUSIVE);
+                            if (m.end() < testText.length() - 1)
+                                ss.setSpan(notMatchedSpan(), m.end(), testText.length(),
+                                        Spanned.SPAN_INCLUSIVE_INCLUSIVE);
+
+                            ss.setSpan(matchedSpan(), m.start(0), m.end(0),
+                                    Spanned.SPAN_INCLUSIVE_INCLUSIVE);
+
+                            if (m.groupCount() > 0) {
+                                for (int g = 1; g <= m.groupCount(); g++) {
+                                    ss.setSpan(capturedSpan(), m.start(g), m.end(g),
+                                            Spanned.SPAN_INCLUSIVE_INCLUSIVE);
+                                }
+                            }
+                        }
+                        else {
+                            patternError = "Pattern does not match";
+                            ss.setSpan(new ForegroundColorSpan(Color.GRAY), 0,
+                                    testText.length() - 1, Spanned.SPAN_INCLUSIVE_INCLUSIVE);
+                        }
+
+                        testMatch = ss;
+                    }
+                }
+                catch (PatternSyntaxException e) {
+                    this.compiledPattern = null;
+                    this.patternError = e.getMessage();
+                }
+            }
+            else {
+                patternError = "Missing pattern";
+            }
+        }
+        public String getPatternError() {
+            return patternError;
+        }
+        public SpannableString testMatch() {
+            return testMatch;
+        }
+    }
+}
index 1e62ff1320cdded3cae4e50ab2c627a68ad51f61..7c3520919232c10c7fc44c42fe19957c649231e4 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2020 Damyan Ivanov.
+ * Copyright © 2021 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
@@ -18,8 +18,8 @@
 package net.ktnx.mobileledger.model;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 
-import net.ktnx.mobileledger.App;
 import net.ktnx.mobileledger.utils.SimpleDate;
 
 import org.jetbrains.annotations.NotNull;
@@ -29,18 +29,26 @@ public class TransactionListItem {
     private SimpleDate date;
     private boolean monthShown;
     private LedgerTransaction transaction;
+    private String boldAccountName;
+    private String runningTotal;
     public TransactionListItem(@NotNull SimpleDate date, boolean monthShown) {
         this.type = Type.DELIMITER;
         this.date = date;
         this.monthShown = monthShown;
     }
-    public TransactionListItem(@NotNull LedgerTransaction transaction) {
+    public TransactionListItem(@NotNull LedgerTransaction transaction,
+                               @Nullable String boldAccountName, @Nullable String runningTotal) {
         this.type = Type.TRANSACTION;
         this.transaction = transaction;
+        this.boldAccountName = boldAccountName;
+        this.runningTotal = runningTotal;
     }
     public TransactionListItem() {
         this.type = Type.HEADER;
     }
+    public String getRunningTotal() {
+        return runningTotal;
+    }
     @NonNull
     public Type getType() {
         return type;
@@ -49,9 +57,8 @@ public class TransactionListItem {
     public SimpleDate getDate() {
         if (date != null)
             return date;
-        if (type == Type.HEADER)
-            throw new IllegalStateException("Header item has no date");
-        transaction.loadData(App.getDatabase());
+        if (type != Type.TRANSACTION)
+            throw new IllegalStateException("Only transaction items have a date");
         return transaction.getDate();
     }
     public boolean isMonthShown() {
@@ -64,5 +71,21 @@ public class TransactionListItem {
                     String.format("Item type is not %s, but %s", Type.TRANSACTION, type));
         return transaction;
     }
-    public enum Type {TRANSACTION, DELIMITER, HEADER}
+    public @Nullable
+    String getBoldAccountName() {
+        return boldAccountName;
+    }
+    public enum Type {
+        TRANSACTION, DELIMITER, HEADER;
+        public static Type valueOf(int i) {
+            if (i == TRANSACTION.ordinal())
+                return TRANSACTION;
+            else if (i == DELIMITER.ordinal())
+                return DELIMITER;
+            else if (i == HEADER.ordinal())
+                return HEADER;
+            else
+                throw new IllegalStateException("Unexpected value: " + i);
+        }
+    }
 }
index 4993df37e1a71a4c93cf0b7561c53314da8d5d3b..759cd90e78451d01887dbb488cb1910d2967ace0 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2019 Damyan Ivanov.
+ * Copyright © 2021 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
@@ -23,7 +23,6 @@ import android.os.Bundle;
 import android.view.View;
 import android.widget.RadioButton;
 import android.widget.RadioGroup;
-import android.widget.Switch;
 import android.widget.TextView;
 
 import androidx.annotation.NonNull;
@@ -34,16 +33,17 @@ import androidx.recyclerview.widget.GridLayoutManager;
 import androidx.recyclerview.widget.LinearLayoutManager;
 import androidx.recyclerview.widget.RecyclerView;
 
-import net.ktnx.mobileledger.App;
+import com.google.android.material.switchmaterial.SwitchMaterial;
+
 import net.ktnx.mobileledger.R;
+import net.ktnx.mobileledger.dao.CurrencyDAO;
+import net.ktnx.mobileledger.db.DB;
+import net.ktnx.mobileledger.db.Profile;
 import net.ktnx.mobileledger.model.Currency;
 import net.ktnx.mobileledger.model.Data;
-import net.ktnx.mobileledger.model.MobileLedgerProfile;
 
 import java.util.ArrayList;
 import java.util.List;
-import java.util.Objects;
-import java.util.concurrent.CopyOnWriteArrayList;
 
 /**
  * A fragment representing a list of Items.
@@ -108,11 +108,19 @@ public class CurrencySelectorFragment extends AppCompatDialogFragment
         model = new ViewModelProvider(this).get(CurrencySelectorModel.class);
         if (onCurrencySelectedListener != null)
             model.setOnCurrencySelectedListener(onCurrencySelectedListener);
-        MobileLedgerProfile profile = Objects.requireNonNull(Data.getProfile());
+        Profile profile = Data.getProfile();
 
-        model.currencies.setValue(new CopyOnWriteArrayList<>(profile.getCurrencies()));
         CurrencySelectorRecyclerViewAdapter adapter = new CurrencySelectorRecyclerViewAdapter();
-        model.currencies.observe(this, adapter::submitList);
+        DB.get()
+          .getCurrencyDAO()
+          .getAll()
+          .observe(this, list -> {
+              List<String> strings = new ArrayList<>();
+              for (net.ktnx.mobileledger.db.Currency c : list) {
+                  strings.add(c.getName());
+              }
+              adapter.submitList(strings);
+          });
 
         recyclerView.setAdapter(adapter);
         adapter.setCurrencySelectedListener(this);
@@ -122,6 +130,8 @@ public class CurrencySelectorFragment extends AppCompatDialogFragment
         final TextView tvNoCurrBtn = csd.findViewById(R.id.btn_no_currency);
         final TextView tvAddCurrOkBtn = csd.findViewById(R.id.btn_add_currency);
         final TextView tvAddCurrBtn = csd.findViewById(R.id.btn_add_new);
+        final SwitchMaterial gap = csd.findViewById(R.id.currency_gap);
+        final RadioGroup rgPosition = csd.findViewById(R.id.position_radio_group);
 
         tvNewCurrName.setVisibility(View.GONE);
         tvAddCurrOkBtn.setVisibility(View.GONE);
@@ -141,15 +151,14 @@ public class CurrencySelectorFragment extends AppCompatDialogFragment
         });
 
         tvAddCurrOkBtn.setOnClickListener(v -> {
-
-
             String currName = String.valueOf(tvNewCurrName.getText());
             if (!currName.isEmpty()) {
-                List<Currency> list = new ArrayList<>(model.currencies.getValue());
-                // FIXME hardcoded position and gap setting
-                list.add(new Currency(profile, String.valueOf(tvNewCurrName.getText()),
-                        Currency.Position.after, false));
-                model.currencies.setValue(list);
+                DB.get()
+                  .getCurrencyDAO()
+                  .insert(new net.ktnx.mobileledger.db.Currency(0,
+                          String.valueOf(tvNewCurrName.getText()),
+                          (rgPosition.getCheckedRadioButtonId() == R.id.currency_position_left)
+                          ? "before" : "after", gap.isChecked()));
             }
 
             tvNewCurrName.setVisibility(View.GONE);
@@ -172,7 +181,6 @@ public class CurrencySelectorFragment extends AppCompatDialogFragment
         else
             rbPositionRight.toggle();
 
-        RadioGroup rgPosition = csd.findViewById(R.id.position_radio_group);
         rgPosition.setOnCheckedChangeListener((group, checkedId) -> {
             if (checkedId == R.id.currency_position_left)
                 Data.currencySymbolPosition.setValue(Currency.Position.before);
@@ -180,8 +188,6 @@ public class CurrencySelectorFragment extends AppCompatDialogFragment
                 Data.currencySymbolPosition.setValue(Currency.Position.after);
         });
 
-        Switch gap = csd.findViewById(R.id.currency_gap);
-
         gap.setChecked(Data.currencyGap.getValue());
 
         gap.setOnCheckedChangeListener((v, checked) -> Data.currencyGap.setValue(checked));
@@ -191,8 +197,13 @@ public class CurrencySelectorFragment extends AppCompatDialogFragment
                                                                            visible ? View.VISIBLE
                                                                                    : View.GONE));
 
-        if ((savedInstanceState != null) ? savedInstanceState.getBoolean(ARG_SHOW_PARAMS,
-                DEFAULT_SHOW_PARAMS) : DEFAULT_SHOW_PARAMS)
+        final boolean showParams;
+        if (getArguments() == null)
+            showParams = DEFAULT_SHOW_PARAMS;
+        else
+            showParams = getArguments().getBoolean(ARG_SHOW_PARAMS, DEFAULT_SHOW_PARAMS);
+
+        if (showParams)
             model.showPositionAndPadding();
         else
             model.hidePositionAndPadding();
@@ -209,19 +220,18 @@ public class CurrencySelectorFragment extends AppCompatDialogFragment
         model.resetOnCurrencySelectedListener();
     }
     @Override
-    public void onCurrencySelected(Currency item) {
+    public void onCurrencySelected(String item) {
         model.triggerOnCurrencySelectedListener(item);
 
         dismiss();
     }
 
     @Override
-    public void onCurrencyLongClick(Currency item) {
-        ArrayList<Currency> list = new ArrayList<>(model.currencies.getValue());
-        App.getDatabase()
-           .execSQL("delete from currencies where id=?", new Object[]{item.getId()});
-        list.remove(item);
-        model.currencies.setValue(list);
+    public void onCurrencyLongClick(String item) {
+        CurrencyDAO dao = DB.get()
+                            .getCurrencyDAO();
+        dao.getByName(item)
+           .observe(this, dao::deleteSync);
     }
     public void showPositionAndPadding() {
         deferredShowPositionAndPadding = true;
index 4b346cdf7759d16ceb4b7e3bea18f3cd51ab8d2e..0fca9d6caf813827d8347eb49814ac5fd2277e1d 100644 (file)
@@ -22,18 +22,10 @@ import androidx.lifecycle.MutableLiveData;
 import androidx.lifecycle.Observer;
 import androidx.lifecycle.ViewModel;
 
-import net.ktnx.mobileledger.model.Currency;
-
-import java.util.ArrayList;
-import java.util.List;
-
 public class CurrencySelectorModel extends ViewModel {
-    public final MutableLiveData<List<Currency>> currencies;
     private final MutableLiveData<Boolean> positionAndPaddingVisible = new MutableLiveData<>(true);
     private OnCurrencySelectedListener selectionListener;
-    public CurrencySelectorModel() {
-        this.currencies = new MutableLiveData<>(new ArrayList<>());
-    }
+    public CurrencySelectorModel() { }
     public void showPositionAndPadding() {
         positionAndPaddingVisible.postValue(true);
     }
@@ -50,7 +42,7 @@ public class CurrencySelectorModel extends ViewModel {
     void resetOnCurrencySelectedListener() {
         selectionListener = null;
     }
-    void triggerOnCurrencySelectedListener(Currency c) {
+    void triggerOnCurrencySelectedListener(String c) {
         if (selectionListener != null)
             selectionListener.onCurrencySelected(c);
     }
index b7d90a3a87eb48bd6c9037365ac195b3ad1c7540..7d4c60bed2b1393c2dfd7f756c5c6f66f32aabee 100644 (file)
@@ -22,6 +22,8 @@ import android.view.View;
 import android.view.ViewGroup;
 import android.widget.TextView;
 
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.DiffUtil;
 import androidx.recyclerview.widget.ListAdapter;
 import androidx.recyclerview.widget.RecyclerView;
 
@@ -35,12 +37,24 @@ import org.jetbrains.annotations.NotNull;
  * specified {@link OnCurrencySelectedListener}.
  */
 public class CurrencySelectorRecyclerViewAdapter
-        extends ListAdapter<Currency, CurrencySelectorRecyclerViewAdapter.ViewHolder> {
+        extends ListAdapter<String, CurrencySelectorRecyclerViewAdapter.ViewHolder> {
+    private static final DiffUtil.ItemCallback<String> DIFF_CALLBACK =
+            new DiffUtil.ItemCallback<String>() {
+                @Override
+                public boolean areItemsTheSame(@NonNull String oldItem, @NonNull String newItem) {
+                    return oldItem.equals(newItem);
+                }
+                @Override
+                public boolean areContentsTheSame(@NonNull String oldItem,
+                                                  @NonNull String newItem) {
+                    return true;
+                }
+            };
 
     private OnCurrencySelectedListener currencySelectedListener;
     private OnCurrencyLongClickListener currencyLongClickListener;
     public CurrencySelectorRecyclerViewAdapter() {
-        super(Currency.DIFF_CALLBACK);
+        super(DIFF_CALLBACK);
     }
     @NotNull
     @Override
@@ -60,7 +74,7 @@ public class CurrencySelectorRecyclerViewAdapter
     public void resetCurrencySelectedListener() {
         currencySelectedListener = null;
     }
-    public void notifyCurrencySelected(Currency currency) {
+    public void notifyCurrencySelected(String currency) {
         if (null != currencySelectedListener)
             currencySelectedListener.onCurrencySelected(currency);
     }
@@ -68,14 +82,14 @@ public class CurrencySelectorRecyclerViewAdapter
         this.currencyLongClickListener = listener;
     }
     public void resetCurrencyLockClickListener() { currencyLongClickListener = null; }
-    private void notifyCurrencyLongClicked(Currency mItem) {
+    private void notifyCurrencyLongClicked(String mItem) {
         if (null != currencyLongClickListener)
             currencyLongClickListener.onCurrencyLongClick(mItem);
     }
 
     public class ViewHolder extends RecyclerView.ViewHolder {
         private final TextView mNameView;
-        private Currency mItem;
+        private String mItem;
 
         ViewHolder(View view) {
             super(view);
@@ -93,9 +107,9 @@ public class CurrencySelectorRecyclerViewAdapter
         public String toString() {
             return super.toString() + " '" + mNameView.getText() + "'";
         }
-        void bindTo(Currency item) {
+        void bindTo(String item) {
             mItem = item;
-            mNameView.setText(item.getName());
+            mNameView.setText(item);
         }
     }
 }
index f5fe08cbd7c9040caa441b4b6550cf41d5c54461..54a2093247e47ccf73982b1c64d5ce5d7a2a2ca4 100644 (file)
@@ -26,7 +26,7 @@ import androidx.annotation.Nullable;
 import androidx.appcompat.app.AppCompatDialogFragment;
 
 import net.ktnx.mobileledger.R;
-import net.ktnx.mobileledger.model.MobileLedgerProfile;
+import net.ktnx.mobileledger.model.FutureDates;
 import net.ktnx.mobileledger.utils.SimpleDate;
 
 import java.util.Calendar;
@@ -54,8 +54,8 @@ public class DatePickerFragment extends AppCompatDialogFragment
         else
             this.maxDate = maxDate.toDate().getTime();
     }
-    public void setFutureDates(MobileLedgerProfile.FutureDates futureDates) {
-        if (futureDates == MobileLedgerProfile.FutureDates.All) {
+    public void setFutureDates(FutureDates futureDates) {
+        if (futureDates == FutureDates.All) {
             maxDate = Long.MAX_VALUE;
         }
         else {
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/FabManager.java b/app/src/main/java/net/ktnx/mobileledger/ui/FabManager.java
new file mode 100644 (file)
index 0000000..bbacc3e
--- /dev/null
@@ -0,0 +1,201 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui;
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.TimeInterpolator;
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.MotionEvent;
+import android.view.ViewGroup;
+import android.view.ViewPropertyAnimator;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
+
+import net.ktnx.mobileledger.utils.DimensionUtils;
+import net.ktnx.mobileledger.utils.Logger;
+
+public class FabManager {
+    private static final boolean FAB_SHOWN = true;
+    private static final boolean FAB_HIDDEN = false;
+    private static final int AUTO_SHOW_DELAY_MILLS = 4000;
+    private final FloatingActionButton fab;
+    private boolean wantedFabState = FAB_SHOWN;
+    private ViewPropertyAnimator fabSlideAnimator;
+    private int fabVerticalOffset;
+    public FabManager(FloatingActionButton fab) {
+        this.fab = fab;
+    }
+    public static void handle(FabHandler fabHandler, RecyclerView recyclerView) {
+        new ScrollFabHandler(fabHandler, recyclerView);
+    }
+    private void slideFabTo(int target, long duration, TimeInterpolator interpolator) {
+        fabSlideAnimator = fab.animate()
+                              .translationY((float) target)
+                              .setInterpolator(interpolator)
+                              .setDuration(duration)
+                              .setListener(new AnimatorListenerAdapter() {
+                                  public void onAnimationEnd(Animator animation) {
+                                      fabSlideAnimator = null;
+                                  }
+                              });
+    }
+    public void showFab() {
+        if (wantedFabState == FAB_SHOWN) {
+//            Logger.debug("fab", "Ignoring request to show already visible FAB");
+            return;
+        }
+
+//        b.btnAddTransaction.show();
+        if (this.fabSlideAnimator != null) {
+            this.fabSlideAnimator.cancel();
+            fab.clearAnimation();
+        }
+
+        Logger.debug("fab", "Showing FAB");
+        wantedFabState = FAB_SHOWN;
+        slideFabTo(0, 200L,
+                com.google.android.material.animation.AnimationUtils.LINEAR_OUT_SLOW_IN_INTERPOLATOR);
+    }
+    public void hideFab() {
+        if (wantedFabState == FAB_HIDDEN) {
+//            Logger.debug("fab", "Ignoring request to hide FAB -- already hidden");
+            return;
+        }
+
+        calcVerticalFabOffset();
+
+//        b.btnAddTransaction.hide();
+        if (this.fabSlideAnimator != null) {
+            this.fabSlideAnimator.cancel();
+            fab.clearAnimation();
+        }
+
+        Logger.debug("fab", "Hiding FAB");
+        wantedFabState = FAB_HIDDEN;
+        slideFabTo(fabVerticalOffset, 150L,
+                com.google.android.material.animation.AnimationUtils.FAST_OUT_LINEAR_IN_INTERPOLATOR);
+    }
+    private void calcVerticalFabOffset() {
+        if (fabVerticalOffset > 0)
+            return;// already calculated
+        fab.measure(0, 0);
+
+        int height = fab.getMeasuredHeight();
+
+        int bottomMargin;
+
+        ViewGroup.LayoutParams layoutParams = fab.getLayoutParams();
+        if (layoutParams instanceof ViewGroup.MarginLayoutParams)
+            bottomMargin = ((ViewGroup.MarginLayoutParams) layoutParams).bottomMargin;
+        else
+            throw new RuntimeException("Unsupported layout params " + layoutParams.getClass()
+                                                                                  .getCanonicalName());
+
+        fabVerticalOffset = height + bottomMargin;
+    }
+    public interface FabHandler {
+        Context getContext();
+
+        void showManagedFab();
+
+        void hideManagedFab();
+    }
+
+    public static class ScrollFabHandler {
+        final private FabHandler fabHandler;
+        private int generation = 0;
+        @SuppressLint("ClickableViewAccessibility")
+        ScrollFabHandler(FabHandler fabHandler, RecyclerView recyclerView) {
+            this.fabHandler = fabHandler;
+            final float triggerAbsolutePixels = DimensionUtils.dp2px(fabHandler.getContext(), 20f);
+            final float triggerRelativePixels = triggerAbsolutePixels / 4f;
+            recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
+                @Override
+                public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
+//                    Logger.debug("touch", "Scrolled " + dy);
+                    if (dy <= 0) {
+                        showFab();
+                    }
+                    else
+                        hideFab();
+
+                    super.onScrolled(recyclerView, dx, dy);
+                }
+            });
+            recyclerView.addOnItemTouchListener(new RecyclerView.SimpleOnItemTouchListener() {
+                private float absoluteAnchor = -1;
+                @Override
+                public boolean onInterceptTouchEvent(@NonNull RecyclerView rv,
+                                                     @NonNull MotionEvent e) {
+                    switch (e.getActionMasked()) {
+                        case MotionEvent.ACTION_DOWN:
+                            absoluteAnchor = e.getRawY();
+//                        Logger.debug("touch",
+//                                String.format(Locale.US, "Touch down at %4.2f", absoluteAnchor));
+                            break;
+                        case MotionEvent.ACTION_MOVE:
+                            if (absoluteAnchor < 0)
+                                break;
+
+                            final float absoluteY = e.getRawY();
+//                        Logger.debug("touch", String.format(Locale.US, "Move to %4.2f",
+//                        absoluteY));
+
+                            if (absoluteY > absoluteAnchor + triggerAbsolutePixels) {
+                                // swipe down
+//                            Logger.debug("touch", "SHOW");
+                                showFab();
+                                absoluteAnchor = absoluteY;
+                            }
+                            else if (absoluteY < absoluteAnchor - triggerAbsolutePixels) {
+                                // swipe up
+//                            Logger.debug("touch", "HIDE");
+                                hideFab();
+                                absoluteAnchor = absoluteY;
+                            }
+
+                            break;
+                    }
+                    return false;
+                }
+            });
+        }
+        private void hideFab() {
+            generation++;
+            int thisGeneration = generation;
+            fabHandler.hideManagedFab();
+            new Handler(Looper.getMainLooper()).postDelayed(() -> {
+                if (generation != thisGeneration)
+                    return;
+
+                showFab();
+            }, AUTO_SHOW_DELAY_MILLS);
+        }
+        private void showFab() {
+            generation++;
+            fabHandler.showManagedFab();
+        }
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/HelpDialog.java b/app/src/main/java/net/ktnx/mobileledger/ui/HelpDialog.java
new file mode 100644 (file)
index 0000000..2a2bf03
--- /dev/null
@@ -0,0 +1,79 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.TextUtils;
+import android.text.method.LinkMovementMethod;
+import android.text.style.URLSpan;
+import android.widget.TextView;
+
+import androidx.annotation.ArrayRes;
+import androidx.annotation.StringRes;
+
+import net.ktnx.mobileledger.R;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class HelpDialog {
+    private final static Pattern MARKDOWN_LINK_PATTERN =
+            Pattern.compile("\\[([^\\[]+)]\\(([^)]*)\\)");
+    public static void show(Context context, @StringRes int title, @ArrayRes int content) {
+        AlertDialog.Builder adb = new AlertDialog.Builder(context);
+        adb.setTitle(title);
+        String message = TextUtils.join("\n\n", context.getResources()
+                                                       .getStringArray(content));
+
+        SpannableStringBuilder richTextMessage = new SpannableStringBuilder();
+        while (true) {
+            Matcher m = MARKDOWN_LINK_PATTERN.matcher(message);
+            if (m.find()) {
+                richTextMessage.append(message.substring(0, m.start()));
+                String linkText = m.group(1);
+                assert linkText != null;
+                String linkURL = m.group(2);
+                assert linkURL != null;
+
+                if (linkText.isEmpty())
+                    linkText = linkURL;
+
+                int spanStart = richTextMessage.length();
+                richTextMessage.append(linkText);
+                richTextMessage.setSpan(new URLSpan(linkURL), spanStart,
+                        spanStart + linkText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+                URLSpan linkSpan = new URLSpan(linkText);
+
+                message = message.substring(m.end());
+            }
+            else {
+                richTextMessage.append(message);
+                break;
+            }
+        }
+        adb.setMessage(richTextMessage);
+        adb.setPositiveButton(R.string.close_button, (dialog, buttonId) -> dialog.dismiss());
+        final AlertDialog dialog = adb.create();
+        dialog.show();
+        ((TextView) dialog.findViewById(android.R.id.message)).setMovementMethod(
+                LinkMovementMethod.getInstance());
+    }
+}
index 44a6f303be75fac7d193b16cee33400af49d5e9b..a8b957dc330be48f6a304cce50bd5f5012468b11 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2020 Damyan Ivanov.
+ * Copyright © 2024 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
 
 package net.ktnx.mobileledger.ui;
 
-import android.database.Cursor;
-import android.database.sqlite.SQLiteDatabase;
-import android.os.AsyncTask;
-import android.os.Build;
-import android.text.TextUtils;
-
-import androidx.annotation.Nullable;
 import androidx.lifecycle.LiveData;
 import androidx.lifecycle.MutableLiveData;
 import androidx.lifecycle.ViewModel;
 
-import net.ktnx.mobileledger.App;
 import net.ktnx.mobileledger.async.RetrieveTransactionsTask;
 import net.ktnx.mobileledger.async.TransactionAccumulator;
-import net.ktnx.mobileledger.async.UpdateTransactionsTask;
-import net.ktnx.mobileledger.model.AccountListItem;
+import net.ktnx.mobileledger.db.Profile;
 import net.ktnx.mobileledger.model.Data;
 import net.ktnx.mobileledger.model.LedgerAccount;
 import net.ktnx.mobileledger.model.LedgerTransaction;
-import net.ktnx.mobileledger.model.MobileLedgerProfile;
 import net.ktnx.mobileledger.model.TransactionListItem;
-import net.ktnx.mobileledger.utils.LockHolder;
-import net.ktnx.mobileledger.utils.Locker;
 import net.ktnx.mobileledger.utils.Logger;
-import net.ktnx.mobileledger.utils.MLDB;
 import net.ktnx.mobileledger.utils.SimpleDate;
 
 import java.util.ArrayList;
-import java.util.Date;
-import java.util.HashMap;
-import java.util.Iterator;
 import java.util.List;
 import java.util.Locale;
-import java.util.Map;
-
-import static net.ktnx.mobileledger.utils.Logger.debug;
 
 public class MainModel extends ViewModel {
     public final MutableLiveData<Integer> foundTransactionItemIndex = new MutableLiveData<>(null);
     private final MutableLiveData<Boolean> updatingFlag = new MutableLiveData<>(false);
-    private final MutableLiveData<String> accountFilter = new MutableLiveData<>();
+    private final MutableLiveData<Boolean> showZeroBalanceAccounts = new MutableLiveData<>(true);
+    private final MutableLiveData<String> accountFilter = new MutableLiveData<>(null);
     private final MutableLiveData<List<TransactionListItem>> displayedTransactions =
             new MutableLiveData<>(new ArrayList<>());
-    private final MutableLiveData<List<AccountListItem>> displayedAccounts =
-            new MutableLiveData<>();
-    private final Locker accountsLocker = new Locker();
     private final MutableLiveData<String> updateError = new MutableLiveData<>();
-    private final Map<String, LedgerAccount> accountMap = new HashMap<>();
-    private MobileLedgerProfile profile;
-    private List<LedgerAccount> allAccounts = new ArrayList<>();
     private SimpleDate firstTransactionDate;
     private SimpleDate lastTransactionDate;
     transient private RetrieveTransactionsTask retrieveTransactionsTask;
-    transient private Thread displayedAccountsUpdater;
-    transient private AccountListLoader loader = null;
     private TransactionsDisplayedFilter displayedTransactionsUpdater;
-    public static ArrayList<LedgerAccount> mergeAccountListsFromWeb(List<LedgerAccount> oldList,
-                                                                    List<LedgerAccount> newList) {
-        LedgerAccount oldAcc, newAcc;
-        ArrayList<LedgerAccount> merged = new ArrayList<>();
-
-        Iterator<LedgerAccount> oldIterator = oldList.iterator();
-        Iterator<LedgerAccount> newIterator = newList.iterator();
-
-        while (true) {
-            if (!oldIterator.hasNext()) {
-                // the rest of the incoming are new
-                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
-                    newIterator.forEachRemaining(merged::add);
-                }
-                else {
-                    while (newIterator.hasNext())
-                        merged.add(newIterator.next());
-                }
-                break;
-            }
-            oldAcc = oldIterator.next();
-
-            if (!newIterator.hasNext()) {
-                // no more incoming accounts. ignore the rest of the old
-                break;
-            }
-            newAcc = newIterator.next();
-
-            // ignore now missing old items
-            if (oldAcc.getName()
-                      .compareTo(newAcc.getName()) < 0)
-                continue;
-
-            // add newly found items
-            if (oldAcc.getName()
-                      .compareTo(newAcc.getName()) > 0)
-            {
-                merged.add(newAcc);
-                continue;
-            }
-
-            // two items with same account names; forward-merge UI-controlled fields
-            // it is important that the result list contains a new LedgerAccount instance
-            // so that the change is propagated to the UI
-            newAcc.setExpanded(oldAcc.isExpanded());
-            newAcc.setAmountsExpanded(oldAcc.amountsExpanded());
-            merged.add(newAcc);
-        }
-
-        return merged;
-    }
-    private void setLastUpdateStamp(long transactionCount) {
-        debug("db", "Updating transaction value stamp");
-        Date now = new Date();
-        profile.setLongOption(MLDB.OPT_LAST_SCRAPE, now.getTime());
-        Data.lastUpdateDate.postValue(now);
-    }
-    public void scheduleTransactionListReload() {
-        UpdateTransactionsTask task = new UpdateTransactionsTask();
-        task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, this);
-    }
     public LiveData<Boolean> getUpdatingFlag() {
         return updatingFlag;
     }
     public LiveData<String> getUpdateError() {
         return updateError;
     }
-    public void setProfile(MobileLedgerProfile profile) {
-        stopTransactionsRetrieval();
-        this.profile = profile;
-    }
     public LiveData<List<TransactionListItem>> getDisplayedTransactions() {
         return displayedTransactions;
     }
@@ -157,6 +66,7 @@ public class MainModel extends ViewModel {
     public void setFirstTransactionDate(SimpleDate earliestDate) {
         this.firstTransactionDate = earliestDate;
     }
+    public MutableLiveData<Boolean> getShowZeroBalanceAccounts() {return showZeroBalanceAccounts;}
     public MutableLiveData<String> getAccountFilter() {
         return accountFilter;
     }
@@ -166,88 +76,28 @@ public class MainModel extends ViewModel {
     public void setLastTransactionDate(SimpleDate latestDate) {
         this.lastTransactionDate = latestDate;
     }
-    private void applyTransactionFilter(List<LedgerTransaction> list) {
-        final String accFilter = accountFilter.getValue();
-        ArrayList<TransactionListItem> newList = new ArrayList<>();
-
-        TransactionAccumulator accumulator = new TransactionAccumulator(this);
-        if (TextUtils.isEmpty(accFilter))
-            for (LedgerTransaction tr : list)
-                newList.add(new TransactionListItem(tr));
-        else
-            for (LedgerTransaction tr : list)
-                if (tr.hasAccountNamedLike(accFilter))
-                    newList.add(new TransactionListItem(tr));
-
-        displayedTransactions.postValue(newList);
-    }
     public synchronized void scheduleTransactionListRetrieval() {
         if (retrieveTransactionsTask != null) {
             Logger.debug("db", "Ignoring request for transaction retrieval - already active");
             return;
         }
-        MobileLedgerProfile profile = Data.getProfile();
+        Profile profile = Data.getProfile();
+        assert profile != null;
 
-        retrieveTransactionsTask = new RetrieveTransactionsTask(this, profile, allAccounts);
+        retrieveTransactionsTask = new RetrieveTransactionsTask(profile);
         Logger.debug("db", "Created a background transaction retrieval task");
 
-        retrieveTransactionsTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+        retrieveTransactionsTask.start();
     }
     public synchronized void stopTransactionsRetrieval() {
         if (retrieveTransactionsTask != null)
-            retrieveTransactionsTask.cancel(true);
+            retrieveTransactionsTask.interrupt();
+        else
+            Data.backgroundTaskProgress.setValue(null);
     }
     public void transactionRetrievalDone() {
         retrieveTransactionsTask = null;
     }
-    public synchronized Locker lockAccountsForWriting() {
-        accountsLocker.lockForWriting();
-        return accountsLocker;
-    }
-    public void mergeAccountListFromWeb(List<LedgerAccount> newList) {
-
-        try (LockHolder l = accountsLocker.lockForWriting()) {
-            allAccounts = mergeAccountListsFromWeb(allAccounts, newList);
-            updateAccountsMap(allAccounts);
-        }
-    }
-    public LiveData<List<AccountListItem>> getDisplayedAccounts() {
-        return displayedAccounts;
-    }
-    synchronized public void scheduleAccountListReload() {
-        Logger.debug("async-acc", "scheduleAccountListReload() enter");
-        if ((loader != null) && loader.isAlive()) {
-            Logger.debug("async-acc", "returning early - loader already active");
-            return;
-        }
-
-        loader = new AccountListLoader(profile, this);
-        loader.start();
-    }
-    public synchronized void setAndStoreAccountAndTransactionListFromWeb(
-            List<LedgerAccount> accounts, List<LedgerTransaction> transactions) {
-        profile.storeAccountAndTransactionListAsync(accounts, transactions);
-
-        setLastUpdateStamp(transactions.size());
-
-        mergeAccountListFromWeb(accounts);
-        updateDisplayedAccounts();
-
-        updateDisplayedTransactionsFromWeb(transactions);
-    }
-    synchronized public void abortAccountListReload() {
-        if (loader == null)
-            return;
-        loader.interrupt();
-        loader = null;
-    }
-    synchronized public void updateDisplayedAccounts() {
-        if (displayedAccountsUpdater != null) {
-            displayedAccountsUpdater.interrupt();
-        }
-        displayedAccountsUpdater = new AccountListDisplayedFilter(this, allAccounts);
-        displayedAccountsUpdater.start();
-    }
     synchronized public void updateDisplayedTransactionsFromWeb(List<LedgerTransaction> list) {
         if (displayedTransactionsUpdater != null) {
             displayedTransactionsUpdater.interrupt();
@@ -255,133 +105,13 @@ public class MainModel extends ViewModel {
         displayedTransactionsUpdater = new TransactionsDisplayedFilter(this, list);
         displayedTransactionsUpdater.start();
     }
-    public List<LedgerAccount> getAllAccounts() {
-        return allAccounts;
-    }
-    private void updateAccountsMap(List<LedgerAccount> newAccounts) {
-        accountMap.clear();
-        for (LedgerAccount acc : newAccounts) {
-            accountMap.put(acc.getName(), acc);
-        }
-    }
-    @Nullable
-    public LedgerAccount locateAccount(String name) {
-        return accountMap.get(name);
-    }
     public void clearUpdateError() {
         updateError.postValue(null);
     }
-    public void clearAccounts() { displayedAccounts.postValue(new ArrayList<>()); }
     public void clearTransactions() {
         displayedTransactions.setValue(new ArrayList<>());
     }
 
-    static class AccountListLoader extends Thread {
-        private final MobileLedgerProfile profile;
-        private final MainModel model;
-        AccountListLoader(MobileLedgerProfile profile, MainModel model) {
-            this.profile = profile;
-            this.model = model;
-        }
-        @Override
-        public void run() {
-            Logger.debug("async-acc", "AccountListLoader::run() entered");
-            String profileUUID = profile.getUuid();
-            ArrayList<LedgerAccount> list = new ArrayList<>();
-            HashMap<String, LedgerAccount> map = new HashMap<>();
-
-            String sql = "SELECT a.name, a.expanded, a.amounts_expanded";
-            sql += " from accounts a WHERE a.profile = ?";
-            sql += " ORDER BY a.name";
-
-            SQLiteDatabase db = App.getDatabase();
-            Logger.debug("async-acc", "AccountListLoader::run() connected to DB");
-            try (Cursor cursor = db.rawQuery(sql, new String[]{profileUUID})) {
-                Logger.debug("async-acc", "AccountListLoader::run() executed query");
-                while (cursor.moveToNext()) {
-                    if (isInterrupted())
-                        return;
-
-                    final String accName = cursor.getString(0);
-//                    debug("accounts",
-//                            String.format("Read account '%s' from DB [%s]", accName,
-//                            profileUUID));
-                    String parentName = LedgerAccount.extractParentName(accName);
-                    LedgerAccount parent;
-                    if (parentName != null) {
-                        parent = map.get(parentName);
-                        if (parent == null)
-                            throw new IllegalStateException(
-                                    String.format("Can't load account '%s': parent '%s' not loaded",
-                                            accName, parentName));
-                        parent.setHasSubAccounts(true);
-                    }
-                    else
-                        parent = null;
-
-                    LedgerAccount acc = new LedgerAccount(profile, accName, parent);
-                    acc.setExpanded(cursor.getInt(1) == 1);
-                    acc.setAmountsExpanded(cursor.getInt(2) == 1);
-                    acc.setHasSubAccounts(false);
-
-                    try (Cursor c2 = db.rawQuery(
-                            "SELECT value, currency FROM account_values WHERE profile = ?" + " " +
-                            "AND account = ?", new String[]{profileUUID, accName}))
-                    {
-                        while (c2.moveToNext()) {
-                            acc.addAmount(c2.getFloat(0), c2.getString(1));
-                        }
-                    }
-
-                    list.add(acc);
-                    map.put(accName, acc);
-                }
-                Logger.debug("async-acc", "AccountListLoader::run() query execution done");
-            }
-
-            if (isInterrupted())
-                return;
-
-            Logger.debug("async-acc", "AccountListLoader::run() posting new list");
-            model.allAccounts = list;
-            model.updateAccountsMap(list);
-            model.updateDisplayedAccounts();
-        }
-    }
-
-    static class AccountListDisplayedFilter extends Thread {
-        private final MainModel model;
-        private final List<LedgerAccount> list;
-        AccountListDisplayedFilter(MainModel model, List<LedgerAccount> list) {
-            this.model = model;
-            this.list = list;
-        }
-        @Override
-        public void run() {
-            List<AccountListItem> newDisplayed = new ArrayList<>();
-            Logger.debug("dFilter", "waiting for synchronized block");
-            Logger.debug("dFilter", String.format(Locale.US,
-                    "entered synchronized block (about to examine %d accounts)", list.size()));
-            newDisplayed.add(new AccountListItem());    // header
-
-            int count = 0;
-            for (LedgerAccount a : list) {
-                if (isInterrupted())
-                    return;
-
-                if (a.isVisible()) {
-                    newDisplayed.add(new AccountListItem(a));
-                    count++;
-                }
-            }
-            if (!isInterrupted()) {
-                model.displayedAccounts.postValue(newDisplayed);
-                Data.lastUpdateAccountCount.postValue(count);
-            }
-            Logger.debug("dFilter", "left synchronized block");
-        }
-    }
-
     static class TransactionsDisplayedFilter extends Thread {
         private final MainModel model;
         private final List<LedgerTransaction> list;
@@ -392,13 +122,12 @@ public class MainModel extends ViewModel {
         @Override
         public void run() {
             List<LedgerAccount> newDisplayed = new ArrayList<>();
-            Logger.debug("dFilter", "waiting for synchronized block");
             Logger.debug("dFilter", String.format(Locale.US,
                     "entered synchronized block (about to examine %d transactions)", list.size()));
             String accNameFilter = model.getAccountFilter()
                                         .getValue();
 
-            TransactionAccumulator acc = new TransactionAccumulator(model);
+            TransactionAccumulator acc = new TransactionAccumulator(accNameFilter, accNameFilter);
             for (LedgerTransaction tr : list) {
                 if (isInterrupted()) {
                     return;
@@ -408,10 +137,12 @@ public class MainModel extends ViewModel {
                     acc.put(tr, tr.getDate());
                 }
             }
-            if (!isInterrupted()) {
-                acc.done();
-            }
-            Logger.debug("dFilter", "left synchronized block");
+
+            if (isInterrupted())
+                return;
+
+            acc.publishResults(model);
+            Logger.debug("dFilter", "transaction list updated");
         }
     }
 }
index 74247189649883fbb1be0c7012cf0f5980b8c804..658ce66b9b8b13252787c5539f3ff65b7384a13c 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2020 Damyan Ivanov.
+ * Copyright © 2021 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
 
 package net.ktnx.mobileledger.ui;
 
-import android.view.MotionEvent;
-
 import androidx.annotation.NonNull;
 import androidx.fragment.app.Fragment;
-import androidx.recyclerview.widget.RecyclerView;
 import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
 
 import net.ktnx.mobileledger.ui.activity.MainActivity;
 import net.ktnx.mobileledger.ui.transaction_list.TransactionListAdapter;
 import net.ktnx.mobileledger.utils.Colors;
-import net.ktnx.mobileledger.utils.DimensionUtils;
 
-public class MobileLedgerListFragment extends Fragment {
-    public SwipeRefreshLayout refreshLayout;
+public abstract class MobileLedgerListFragment extends Fragment {
     public TransactionListAdapter modelAdapter;
-    protected RecyclerView root;
+    public abstract SwipeRefreshLayout getRefreshLayout();
     @NonNull
     public MainActivity getMainActivity() {
         return (MainActivity) requireActivity();
     }
     protected void themeChanged(Integer counter) {
-        refreshLayout.setColorSchemeColors(Colors.getSwipeCircleColors());
+        getRefreshLayout().setColorSchemeColors(Colors.getSwipeCircleColors());
     }
     public void onBackgroundTaskRunningChanged(Boolean isRunning) {
         if (getActivity() == null)
             return;
-        if (refreshLayout == null)
+        SwipeRefreshLayout l = getRefreshLayout();
+        if (l == null)
             return;
-        refreshLayout.setRefreshing(isRunning);
-    }
-    protected void manageFabOnScroll() {
-        final MainActivity mainActivity = getMainActivity();
-        int triggerPixels = DimensionUtils.dp2px(mainActivity, 30f);
-        root.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
-            private float upAnchor = -1;
-            private float lastY;
-            @Override
-            public boolean onInterceptTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) {
-                switch (e.getActionMasked()) {
-                    case MotionEvent.ACTION_DOWN:
-                        lastY = upAnchor = e.getAxisValue(MotionEvent.AXIS_Y);
-                        break;
-                    case MotionEvent.ACTION_MOVE:
-                        final float currentY = e.getAxisValue(MotionEvent.AXIS_Y);
-                        if (currentY > lastY) {
-                            // swipe down
-                            upAnchor = lastY;
-
-                            mainActivity.fabShouldShow();
-                        }
-                        else {
-                            // swipe up
-                            if (currentY < upAnchor - triggerPixels)
-                                mainActivity.fabHide();
-                        }
-
-                        lastY = currentY;
-
-                        break;
-                }
-                return false;
-            }
-            @Override
-            public void onTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) {
-            }
-            @Override
-            public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {
-            }
-        });
+        l.setRefreshing(isRunning);
     }
 }
index f681f9990af8ea7149c5f51548249c57ac39dbc5..e464e73e674d8f9945583fd46df1cc264d3f2e5a 100644 (file)
@@ -17,8 +17,6 @@
 
 package net.ktnx.mobileledger.ui;
 
-import net.ktnx.mobileledger.model.Currency;
-
 /**
  * This interface must be implemented by activities that contain this
  * fragment to allow an interaction in this fragment to be communicated
@@ -30,5 +28,5 @@ import net.ktnx.mobileledger.model.Currency;
  * >Communicating with Other Fragments</a> for more information.
  */
 public interface OnCurrencyLongClickListener {
-    void onCurrencyLongClick(Currency item);
+    void onCurrencyLongClick(String item);
 }
index c12f32a2b517ae08455f1331413be3bb88fcd5a3..94e417e26ddd3a2a8d4fefb6fef9757332dbaec1 100644 (file)
@@ -17,8 +17,6 @@
 
 package net.ktnx.mobileledger.ui;
 
-import net.ktnx.mobileledger.model.Currency;
-
 /**
  * This interface must be implemented by activities that contain this
  * fragment to allow an interaction in this fragment to be communicated
@@ -30,5 +28,5 @@ import net.ktnx.mobileledger.model.Currency;
  * >Communicating with Other Fragments</a> for more information.
  */
 public interface OnCurrencySelectedListener {
-    void onCurrencySelected(Currency item);
+    void onCurrencySelected(String item);
 }
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/OnSourceSelectedListener.java b/app/src/main/java/net/ktnx/mobileledger/ui/OnSourceSelectedListener.java
new file mode 100644 (file)
index 0000000..a294adb
--- /dev/null
@@ -0,0 +1,22 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui;
+
+public interface OnSourceSelectedListener {
+    void onSourceSelected(boolean literal, short group);
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/QR.java b/app/src/main/java/net/ktnx/mobileledger/ui/QR.java
new file mode 100644 (file)
index 0000000..3250039
--- /dev/null
@@ -0,0 +1,57 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+
+import androidx.activity.result.ActivityResultCaller;
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContract;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+public class QR {
+    private static final String SCAN_APP_NAME = "com.google.zxing.client.android.SCAN";
+    public static ActivityResultLauncher<Void> registerLauncher(ActivityResultCaller activity,
+                                                                QRScanResultReceiver resultReceiver) {
+        return activity.registerForActivityResult(new ActivityResultContract<Void, String>() {
+            @NonNull
+            @Override
+            public Intent createIntent(@NonNull Context context, Void input) {
+                final Intent intent = new Intent(SCAN_APP_NAME);
+                intent.putExtra("SCAN_MODE", "QR_CODE_MODE");
+                return intent;
+            }
+            @Override
+            public String parseResult(int resultCode, @Nullable Intent intent) {
+                if (resultCode == Activity.RESULT_CANCELED || intent == null)
+                    return null;
+                return intent.getStringExtra("SCAN_RESULT");
+            }
+        }, resultReceiver::onQRScanResult);
+    }
+    public interface QRScanResultReceiver {
+        void onQRScanResult(String scanned);
+    }
+
+    public interface QRScanTrigger {
+        void triggerQRScan();
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/QRScanCapableFragment.java b/app/src/main/java/net/ktnx/mobileledger/ui/QRScanCapableFragment.java
new file mode 100644 (file)
index 0000000..a23a402
--- /dev/null
@@ -0,0 +1,41 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui;
+
+import android.content.Context;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.annotation.NonNull;
+import androidx.fragment.app.Fragment;
+import androidx.lifecycle.MutableLiveData;
+
+public abstract class QRScanCapableFragment extends Fragment {
+    private static final MutableLiveData<Integer> qrScanTrigger = new MutableLiveData<>();
+    protected final ActivityResultLauncher<Void> scanQrLauncher = QR.registerLauncher(this, this::onQrScanned);
+    public static void triggerQRScan() {
+        qrScanTrigger.setValue(1);
+    }
+    protected abstract void onQrScanned(String text);
+    @Override
+    public void onAttach(@NonNull Context context) {
+        super.onAttach(context);
+        qrScanTrigger.observe(this, ignored -> {
+            scanQrLauncher.launch(null);
+        });
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/TemplateDetailSourceSelectorFragment.java b/app/src/main/java/net/ktnx/mobileledger/ui/TemplateDetailSourceSelectorFragment.java
new file mode 100644 (file)
index 0000000..97c8878
--- /dev/null
@@ -0,0 +1,189 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.StringRes;
+import androidx.appcompat.app.AppCompatDialogFragment;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.recyclerview.widget.GridLayoutManager;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import net.ktnx.mobileledger.R;
+import net.ktnx.mobileledger.databinding.FragmentTemplateDetailSourceSelectorListBinding;
+import net.ktnx.mobileledger.model.TemplateDetailSource;
+import net.ktnx.mobileledger.utils.Logger;
+import net.ktnx.mobileledger.utils.Misc;
+
+import java.util.ArrayList;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * A fragment representing a list of Items.
+ * <p/>
+ * Activities containing this fragment MUST implement the {@link OnSourceSelectedListener}
+ * interface.
+ */
+public class TemplateDetailSourceSelectorFragment extends AppCompatDialogFragment
+        implements OnSourceSelectedListener {
+
+    public static final int DEFAULT_COLUMN_COUNT = 1;
+    public static final String ARG_COLUMN_COUNT = "column-count";
+    public static final String ARG_PATTERN = "pattern";
+    public static final String ARG_TEST_TEXT = "test-text";
+    private int mColumnCount = DEFAULT_COLUMN_COUNT;
+    private ArrayList<TemplateDetailSource> mSources;
+    private TemplateDetailSourceSelectorModel model;
+    private OnSourceSelectedListener onSourceSelectedListener;
+    private @StringRes
+    int mPatternProblem;
+
+    /**
+     * Mandatory empty constructor for the fragment manager to instantiate the
+     * fragment (e.g. upon screen orientation changes).
+     */
+    public TemplateDetailSourceSelectorFragment() {
+    }
+    @SuppressWarnings("unused")
+    public static TemplateDetailSourceSelectorFragment newInstance() {
+        return newInstance(DEFAULT_COLUMN_COUNT, null, null);
+    }
+    public static TemplateDetailSourceSelectorFragment newInstance(int columnCount,
+                                                                   @Nullable String pattern,
+                                                                   @Nullable String testText) {
+        TemplateDetailSourceSelectorFragment fragment = new TemplateDetailSourceSelectorFragment();
+        Bundle args = new Bundle();
+        args.putInt(ARG_COLUMN_COUNT, columnCount);
+        if (pattern != null)
+            args.putString(ARG_PATTERN, pattern);
+        if (testText != null)
+            args.putString(ARG_TEST_TEXT, testText);
+        fragment.setArguments(args);
+        return fragment;
+    }
+    @Override
+    public void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+
+        if (getArguments() != null) {
+            mColumnCount = getArguments().getInt(ARG_COLUMN_COUNT, DEFAULT_COLUMN_COUNT);
+            final String patternText = getArguments().getString(ARG_PATTERN);
+            final String testText = getArguments().getString(ARG_TEST_TEXT);
+            if (Misc.emptyIsNull(patternText) == null) {
+                mPatternProblem = R.string.missing_pattern_error;
+            }
+            else {
+                if (Misc.emptyIsNull(testText) == null) {
+                    mPatternProblem = R.string.missing_test_text;
+                }
+                else {
+                    Pattern pattern = Pattern.compile(patternText);
+                    Matcher matcher = pattern.matcher(testText);
+                    Logger.debug("templates",
+                            String.format("Trying to match pattern '%s' against text '%s'",
+                                    patternText, testText));
+                    if (matcher.find()) {
+                        if (matcher.groupCount() >= 0) {
+                            ArrayList<TemplateDetailSource> list = new ArrayList<>();
+                            for (short g = 1; g <= matcher.groupCount(); g++) {
+                                list.add(new TemplateDetailSource(g, matcher.group(g)));
+                            }
+                            mSources = list;
+                        }
+                        else {
+                            mPatternProblem = R.string.pattern_without_groups;
+                        }
+                    }
+                    else {
+                        mPatternProblem = R.string.pattern_does_not_match;
+                    }
+                }
+            }
+        }
+    }
+    @NonNull
+    @Override
+    public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
+        Context context = requireContext();
+        Dialog csd = new Dialog(context);
+        FragmentTemplateDetailSourceSelectorListBinding b =
+                FragmentTemplateDetailSourceSelectorListBinding.inflate(
+                        LayoutInflater.from(context));
+        csd.setContentView(b.getRoot());
+        csd.setTitle(R.string.choose_template_detail_source_label);
+
+        if (mSources != null && !mSources.isEmpty()) {
+            RecyclerView recyclerView = b.list;
+
+            if (mColumnCount <= 1) {
+                recyclerView.setLayoutManager(new LinearLayoutManager(context));
+            }
+            else {
+                recyclerView.setLayoutManager(new GridLayoutManager(context, mColumnCount));
+            }
+            model = new ViewModelProvider(this).get(TemplateDetailSourceSelectorModel.class);
+            if (onSourceSelectedListener != null)
+                model.setOnSourceSelectedListener(onSourceSelectedListener);
+            model.setSourcesList(mSources);
+
+            TemplateDetailSourceSelectorRecyclerViewAdapter adapter =
+                    new TemplateDetailSourceSelectorRecyclerViewAdapter();
+            model.groups.observe(this, adapter::submitList);
+
+            recyclerView.setAdapter(adapter);
+            adapter.setSourceSelectedListener(this);
+        }
+        else {
+            b.list.setVisibility(View.GONE);
+            b.templateError.setText(
+                    (mPatternProblem != 0) ? mPatternProblem : R.string.pattern_without_groups);
+            b.templateError.setVisibility(View.VISIBLE);
+        }
+
+        b.literalButton.setOnClickListener(v -> onSourceSelected(true, (short) -1));
+
+        return csd;
+    }
+    public void setOnSourceSelectedListener(OnSourceSelectedListener listener) {
+        onSourceSelectedListener = listener;
+
+        if (model != null)
+            model.setOnSourceSelectedListener(listener);
+    }
+    public void resetOnSourceSelectedListener() {
+        model.resetOnSourceSelectedListener();
+    }
+    @Override
+    public void onSourceSelected(boolean literal, short group) {
+        if (model != null)
+            model.triggerOnSourceSelectedListener(literal, group);
+        if (onSourceSelectedListener != null)
+            onSourceSelectedListener.onSourceSelected(literal, group);
+
+        dismiss();
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/TemplateDetailSourceSelectorModel.java b/app/src/main/java/net/ktnx/mobileledger/ui/TemplateDetailSourceSelectorModel.java
new file mode 100644 (file)
index 0000000..46f5885
--- /dev/null
@@ -0,0 +1,46 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui;
+
+import androidx.lifecycle.MutableLiveData;
+import androidx.lifecycle.ViewModel;
+
+import net.ktnx.mobileledger.model.TemplateDetailSource;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class TemplateDetailSourceSelectorModel extends ViewModel {
+    public final MutableLiveData<List<TemplateDetailSource>> groups = new MutableLiveData<>();
+    private OnSourceSelectedListener selectionListener;
+    public TemplateDetailSourceSelectorModel() {
+    }
+    void setOnSourceSelectedListener(OnSourceSelectedListener listener) {
+        selectionListener = listener;
+    }
+    void resetOnSourceSelectedListener() {
+        selectionListener = null;
+    }
+    void triggerOnSourceSelectedListener(boolean literal, short group) {
+        if (selectionListener != null)
+            selectionListener.onSourceSelected(literal, group);
+    }
+    public void setSourcesList(ArrayList<TemplateDetailSource> mSources) {
+        groups.setValue(mSources);
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/TemplateDetailSourceSelectorRecyclerViewAdapter.java b/app/src/main/java/net/ktnx/mobileledger/ui/TemplateDetailSourceSelectorRecyclerViewAdapter.java
new file mode 100644 (file)
index 0000000..060bf13
--- /dev/null
@@ -0,0 +1,95 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui;
+
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.recyclerview.widget.ListAdapter;
+import androidx.recyclerview.widget.RecyclerView;
+
+import net.ktnx.mobileledger.databinding.FragmentTemplateDetailSourceSelectorBinding;
+import net.ktnx.mobileledger.model.TemplateDetailSource;
+
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * {@link RecyclerView.Adapter} that can display a {@link TemplateDetailSource} and makes a call
+ * to the
+ * specified {@link OnSourceSelectedListener}.
+ */
+public class TemplateDetailSourceSelectorRecyclerViewAdapter extends
+        ListAdapter<TemplateDetailSource,
+                TemplateDetailSourceSelectorRecyclerViewAdapter.ViewHolder> {
+
+    private OnSourceSelectedListener sourceSelectedListener;
+    public TemplateDetailSourceSelectorRecyclerViewAdapter() {
+        super(TemplateDetailSource.DIFF_CALLBACK);
+    }
+    @NotNull
+    @Override
+    public ViewHolder onCreateViewHolder(@NotNull ViewGroup parent, int viewType) {
+        FragmentTemplateDetailSourceSelectorBinding b =
+                FragmentTemplateDetailSourceSelectorBinding.inflate(
+                        LayoutInflater.from(parent.getContext()), parent, false);
+        return new ViewHolder(b);
+    }
+
+    @Override
+    public void onBindViewHolder(final ViewHolder holder, int position) {
+        holder.bindTo(getItem(position));
+    }
+    public void setSourceSelectedListener(OnSourceSelectedListener listener) {
+        this.sourceSelectedListener = listener;
+    }
+    public void resetSourceSelectedListener() {
+        sourceSelectedListener = null;
+    }
+    public void notifySourceSelected(TemplateDetailSource item) {
+        if (null != sourceSelectedListener)
+            sourceSelectedListener.onSourceSelected(false, item.getGroupNumber());
+    }
+    public void notifyLiteralSelected() {
+        if (null != sourceSelectedListener)
+            sourceSelectedListener.onSourceSelected(true, (short) -1);
+    }
+    public class ViewHolder extends RecyclerView.ViewHolder {
+        private final FragmentTemplateDetailSourceSelectorBinding b;
+        private TemplateDetailSource mItem;
+
+        ViewHolder(FragmentTemplateDetailSourceSelectorBinding binding) {
+            super(binding.getRoot());
+            b = binding;
+
+            b.getRoot()
+             .setOnClickListener(v -> notifySourceSelected(mItem));
+        }
+
+        @NotNull
+        @Override
+        public String toString() {
+            return super.toString() + " " + b.groupNumber.getText() + ": '" +
+                   b.matchedText.getText() + "'";
+        }
+        void bindTo(TemplateDetailSource item) {
+            mItem = item;
+            b.groupNumber.setText(String.valueOf(item.getGroupNumber()));
+            b.matchedText.setText(item.getMatchedText());
+        }
+    }
+}
index 6faa82a73b8f271a198fe0eeffcc75c90bd17618..dcc16f3678ed3885605d4b85de77653f1f957b9c 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2020 Damyan Ivanov.
+ * Copyright © 2024 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
 
 package net.ktnx.mobileledger.ui.account_summary;
 
-import android.content.Context;
+import static net.ktnx.mobileledger.utils.Logger.debug;
+
 import android.content.res.Resources;
-import android.text.TextUtils;
 import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
-import android.widget.ImageView;
-import android.widget.TextView;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.appcompat.app.AlertDialog;
 import androidx.constraintlayout.widget.ConstraintLayout;
+import androidx.lifecycle.LifecycleOwner;
 import androidx.recyclerview.widget.AsyncListDiffer;
 import androidx.recyclerview.widget.DiffUtil;
 import androidx.recyclerview.widget.RecyclerView;
 
 import net.ktnx.mobileledger.R;
-import net.ktnx.mobileledger.async.DbOpQueue;
+import net.ktnx.mobileledger.dao.BaseDAO;
+import net.ktnx.mobileledger.databinding.AccountListRowBinding;
+import net.ktnx.mobileledger.databinding.AccountListSummaryRowBinding;
+import net.ktnx.mobileledger.db.Account;
+import net.ktnx.mobileledger.db.DB;
 import net.ktnx.mobileledger.model.AccountListItem;
-import net.ktnx.mobileledger.model.Data;
 import net.ktnx.mobileledger.model.LedgerAccount;
-import net.ktnx.mobileledger.model.MobileLedgerProfile;
-import net.ktnx.mobileledger.ui.MainModel;
 import net.ktnx.mobileledger.ui.activity.MainActivity;
-import net.ktnx.mobileledger.utils.Locker;
+import net.ktnx.mobileledger.utils.Logger;
+import net.ktnx.mobileledger.utils.Misc;
 
 import org.jetbrains.annotations.NotNull;
 
 import java.util.List;
 import java.util.Locale;
-import java.util.Observer;
-
-import static net.ktnx.mobileledger.utils.Logger.debug;
 
-public class AccountSummaryAdapter
-        extends RecyclerView.Adapter<AccountSummaryAdapter.LedgerRowHolder> {
+public class AccountSummaryAdapter extends RecyclerView.Adapter<AccountSummaryAdapter.RowHolder> {
     public static final int AMOUNT_LIMIT = 3;
+    private static final int ITEM_TYPE_HEADER = 1;
+    private static final int ITEM_TYPE_ACCOUNT = 2;
     private final AsyncListDiffer<AccountListItem> listDiffer;
-    private final MainModel model;
-    AccountSummaryAdapter(MainModel model) {
-        this.model = model;
+
+    AccountSummaryAdapter() {
+        setHasStableIds(true);
 
         listDiffer = new AsyncListDiffer<>(this, new DiffUtil.ItemCallback<AccountListItem>() {
+            @Nullable
+            @Override
+            public Object getChangePayload(@NonNull AccountListItem oldItem,
+                                           @NonNull AccountListItem newItem) {
+                Change changes = new Change();
+
+                final LedgerAccount oldAcc = oldItem.toAccount()
+                                                    .getAccount();
+                final LedgerAccount newAcc = newItem.toAccount()
+                                                    .getAccount();
+
+                if (!Misc.equalStrings(oldAcc.getName(), newAcc.getName()))
+                    changes.add(Change.NAME);
+
+                if (oldAcc.getLevel() != newAcc.getLevel())
+                    changes.add(Change.LEVEL);
+
+                if (oldAcc.isExpanded() != newAcc.isExpanded())
+                    changes.add(Change.EXPANDED);
+
+                if (oldAcc.amountsExpanded() != newAcc.amountsExpanded())
+                    changes.add(Change.EXPANDED_AMOUNTS);
+
+                if (!oldAcc.getAmountsString()
+                           .equals(newAcc.getAmountsString()))
+                    changes.add(Change.AMOUNTS);
+
+                return changes.toPayload();
+            }
             @Override
             public boolean areItemsTheSame(@NotNull AccountListItem oldItem,
                                            @NotNull AccountListItem newItem) {
                 final AccountListItem.Type oldType = oldItem.getType();
                 final AccountListItem.Type newType = newItem.getType();
-                if (oldType == AccountListItem.Type.HEADER) {
-                    return newType == AccountListItem.Type.HEADER;
-                }
                 if (oldType != newType)
                     return false;
+                if (oldType == AccountListItem.Type.HEADER)
+                    return true;
 
-                return TextUtils.equals(oldItem.getAccount()
-                                               .getName(), newItem.getAccount()
-                                                                  .getName());
+                return oldItem.toAccount()
+                              .getAccount()
+                              .getId() == newItem.toAccount()
+                                                 .getAccount()
+                                                 .getId();
             }
             @Override
             public boolean areContentsTheSame(@NotNull AccountListItem oldItem,
                                               @NotNull AccountListItem newItem) {
-                if (oldItem.getType()
-                           .equals(AccountListItem.Type.HEADER))
-                    return true;
-                return oldItem.getAccount()
-                              .equals(newItem.getAccount());
+                return oldItem.sameContent(newItem);
             }
         });
     }
-
-    public void onBindViewHolder(@NonNull LedgerRowHolder holder, int position) {
-        holder.bindToAccount(listDiffer.getCurrentList()
-                                       .get(position));
+    @Override
+    public long getItemId(int position) {
+        if (position == 0)
+            return 0;
+        return listDiffer.getCurrentList()
+                         .get(position)
+                         .toAccount()
+                         .getAccount()
+                         .getId();
     }
-
-    @NonNull
     @Override
-    public LedgerRowHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
-        View row = LayoutInflater.from(parent.getContext())
-                                 .inflate(R.layout.account_summary_row, parent, false);
-        return new LedgerRowHolder(row);
+    public void onBindViewHolder(@NonNull RowHolder holder, int position,
+                                 @NonNull List<Object> payloads) {
+        holder.bind(listDiffer.getCurrentList()
+                              .get(position), payloads);
+        super.onBindViewHolder(holder, position, payloads);
     }
+    public void onBindViewHolder(@NonNull RowHolder holder, int position) {
+        holder.bind(listDiffer.getCurrentList()
+                              .get(position), null);
+    }
+    @NonNull
+    @Override
+    public RowHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        final LayoutInflater inflater = LayoutInflater.from(parent.getContext());
+
+        final RowHolder result;
+        switch (viewType) {
+            case ITEM_TYPE_HEADER:
+                result = new HeaderRowHolder(
+                        AccountListSummaryRowBinding.inflate(inflater, parent, false));
+                break;
+            case ITEM_TYPE_ACCOUNT:
+                result = new AccountRowHolder(
+                        AccountListRowBinding.inflate(inflater, parent, false));
+                break;
+            default:
+                throw new IllegalStateException("Unexpected value: " + viewType);
+        }
 
+//        Logger.debug("acc-ui", "Creating " + result);
+        return result;
+    }
     @Override
     public int getItemCount() {
         return listDiffer.getCurrentList()
                          .size();
     }
+    @Override
+    public int getItemViewType(int position) {
+        return (position == 0) ? ITEM_TYPE_HEADER : ITEM_TYPE_ACCOUNT;
+    }
     public void setAccounts(List<AccountListItem> newList) {
-        listDiffer.submitList(newList);
+        Misc.onMainThread(() -> listDiffer.submitList(newList));
+    }
+    static class Change {
+        static final int NAME = 1;
+        static final int EXPANDED = 1 << 1;
+        static final int LEVEL = 1 << 2;
+        static final int EXPANDED_AMOUNTS = 1 << 3;
+        static final int AMOUNTS = 1 << 4;
+        private int value = 0;
+        public Change() {
+        }
+        public Change(int initialValue) {
+            value = initialValue;
+        }
+        public void add(int bits) {
+            value = value | bits;
+        }
+        public void add(Change change) {
+            value = value | change.value;
+        }
+        public void remove(int bits) {
+            value = value & (~bits);
+        }
+        public void remove(Change change) {
+            value = value & (~change.value);
+        }
+        public Change toPayload() {
+            if (value == 0)
+                return null;
+            return this;
+        }
+        public boolean has(int bits) {
+            return value == 0 || (value & bits) == bits;
+        }
     }
-    class LedgerRowHolder extends RecyclerView.ViewHolder {
-        private final TextView tvAccountName, tvAccountAmounts;
-        private final ConstraintLayout row;
-        private final View expanderContainer;
-        private final View amountExpanderContainer;
-        private final View lLastUpdate;
-        private final TextView tvLastUpdate;
-        private final View vAccountNameLayout;
-        LedgerAccount mAccount;
-        private AccountListItem.Type lastType;
-        private Observer lastUpdateObserver;
-        public LedgerRowHolder(@NonNull View itemView) {
-            super(itemView);
 
-            row = itemView.findViewById(R.id.account_summary_row);
-            vAccountNameLayout = itemView.findViewById(R.id.account_name_layout);
-            tvAccountName = itemView.findViewById(R.id.account_row_acc_name);
-            tvAccountAmounts = itemView.findViewById(R.id.account_row_acc_amounts);
-            expanderContainer = itemView.findViewById(R.id.account_expander_container);
-            ImageView expander = itemView.findViewById(R.id.account_expander);
-            amountExpanderContainer =
-                    itemView.findViewById(R.id.account_row_amounts_expander_container);
-            lLastUpdate = itemView.findViewById(R.id.last_update_container);
-            tvLastUpdate = itemView.findViewById(R.id.last_update_text);
+    static abstract class RowHolder extends RecyclerView.ViewHolder {
+        public RowHolder(@NonNull View itemView) {
+            super(itemView);
+        }
+        public abstract void bind(AccountListItem accountListItem, @Nullable List<Object> payloads);
+    }
 
-            itemView.setOnLongClickListener(this::onItemLongClick);
-            tvAccountName.setOnLongClickListener(this::onItemLongClick);
-            tvAccountAmounts.setOnLongClickListener(this::onItemLongClick);
-            expanderContainer.setOnLongClickListener(this::onItemLongClick);
-            expander.setOnLongClickListener(this::onItemLongClick);
-            row.setOnLongClickListener(this::onItemLongClick);
+    static class HeaderRowHolder extends RowHolder {
+        private final AccountListSummaryRowBinding b;
+        public HeaderRowHolder(@NonNull AccountListSummaryRowBinding binding) {
+            super(binding.getRoot());
+            b = binding;
+        }
+        @Override
+        public void bind(AccountListItem item, @Nullable List<Object> payloads) {
+            Resources r = itemView.getResources();
+//            Logger.debug("acc", itemView.getContext()
+//                                        .toString());
+            ((AccountListItem.Header) item).getText()
+                                           .observe((LifecycleOwner) itemView.getContext(),
+                                                   b.lastUpdateText::setText);
+        }
+    }
 
-            tvAccountName.setOnClickListener(v -> toggleAccountExpanded());
-            expanderContainer.setOnClickListener(v -> toggleAccountExpanded());
-            expander.setOnClickListener(v -> toggleAccountExpanded());
-            tvAccountAmounts.setOnClickListener(v -> toggleAmountsExpanded());
+    class AccountRowHolder extends AccountSummaryAdapter.RowHolder {
+        private final AccountListRowBinding b;
+        public AccountRowHolder(@NonNull AccountListRowBinding binding) {
+            super(binding.getRoot());
+            b = binding;
 
+            itemView.setOnLongClickListener(this::onItemLongClick);
+            b.accountRowAccName.setOnLongClickListener(this::onItemLongClick);
+            b.accountRowAccAmounts.setOnLongClickListener(this::onItemLongClick);
+            b.accountExpanderContainer.setOnLongClickListener(this::onItemLongClick);
+            b.accountExpander.setOnLongClickListener(this::onItemLongClick);
+
+            b.accountRowAccName.setOnClickListener(v -> toggleAccountExpanded());
+            b.accountExpanderContainer.setOnClickListener(v -> toggleAccountExpanded());
+            b.accountExpander.setOnClickListener(v -> toggleAccountExpanded());
+            b.accountRowAccAmounts.setOnClickListener(v -> toggleAmountsExpanded());
         }
         private void toggleAccountExpanded() {
-            if (!mAccount.hasSubAccounts())
+            LedgerAccount account = getAccount();
+            if (!account.hasSubAccounts())
                 return;
             debug("accounts", "Account expander clicked");
 
-            // make sure we use the same object as the one in the allAccounts list
-            MobileLedgerProfile profile = mAccount.getProfile();
-            if (profile == null) {
-                return;
-            }
-            try (Locker ignored = model.lockAccountsForWriting()) {
-                LedgerAccount realAccount = model.locateAccount(mAccount.getName());
-                if (realAccount == null)
-                    return;
-
-                mAccount = realAccount;
-                mAccount.toggleExpanded();
-            }
-            expanderContainer.animate()
-                             .rotation(mAccount.isExpanded() ? 0 : 180);
-            model.updateDisplayedAccounts();
-
-            DbOpQueue.add("update accounts set expanded=? where name=? and profile=?",
-                    new Object[]{mAccount.isExpanded(), mAccount.getName(), profile.getUuid()
-                    });
-
+            BaseDAO.runAsync(() -> {
+                Account dbo = account.toDBO();
+                dbo.setExpanded(!dbo.isExpanded());
+                Logger.debug("accounts",
+                        String.format(Locale.ROOT, "%s (%d) → %s", account.getName(), dbo.getId(),
+                                dbo.isExpanded() ? "expanded" : "collapsed"));
+                DB.get()
+                  .getAccountDAO()
+                  .updateSync(dbo);
+            });
+        }
+        @NotNull
+        private LedgerAccount getAccount() {
+            return listDiffer.getCurrentList()
+                             .get(getBindingAdapterPosition())
+                             .toAccount()
+                             .getAccount();
         }
         private void toggleAmountsExpanded() {
-            if (mAccount.getAmountCount() <= AMOUNT_LIMIT)
+            LedgerAccount account = getAccount();
+            if (account.getAmountCount() <= AMOUNT_LIMIT)
                 return;
 
-            mAccount.toggleAmountsExpanded();
-            if (mAccount.amountsExpanded()) {
-                tvAccountAmounts.setText(mAccount.getAmountsString());
-                amountExpanderContainer.setVisibility(View.GONE);
+            account.toggleAmountsExpanded();
+            if (account.amountsExpanded()) {
+                b.accountRowAccAmounts.setText(account.getAmountsString());
+                b.accountRowAmountsExpanderContainer.setVisibility(View.GONE);
             }
             else {
-                tvAccountAmounts.setText(mAccount.getAmountsString(AMOUNT_LIMIT));
-                amountExpanderContainer.setVisibility(View.VISIBLE);
+                b.accountRowAccAmounts.setText(account.getAmountsString(AMOUNT_LIMIT));
+                b.accountRowAmountsExpanderContainer.setVisibility(View.VISIBLE);
             }
 
-            MobileLedgerProfile profile = mAccount.getProfile();
-            if (profile == null)
-                return;
-
-            DbOpQueue.add("update accounts set amounts_expanded=? where name=? and profile=?",
-                    new Object[]{mAccount.amountsExpanded(), mAccount.getName(), profile.getUuid()
-                    });
-
+            BaseDAO.runAsync(() -> {
+                Account dbo = account.toDBO();
+                DB.get()
+                  .getAccountDAO()
+                  .updateSync(dbo);
+            });
         }
         private boolean onItemLongClick(View v) {
             MainActivity activity = (MainActivity) v.getContext();
             AlertDialog.Builder builder = new AlertDialog.Builder(activity);
-            final String accountName = mAccount.getName();
+            final String accountName = getAccount().getName();
             builder.setTitle(accountName);
             builder.setItems(R.array.acc_ctx_menu, (dialog, which) -> {
                 if (which == 0) {// show transactions
@@ -213,100 +305,64 @@ public class AccountSummaryAdapter
             builder.show();
             return true;
         }
-        public void bindToAccount(AccountListItem item) {
-            final AccountListItem.Type newType = item.getType();
-            setType(newType);
-
-            switch (newType) {
-                case ACCOUNT:
-                    LedgerAccount acc = item.getAccount();
-
-                    debug("accounts", String.format(Locale.US, "Binding to '%s'", acc.getName()));
-                    Context ctx = row.getContext();
-                    Resources rm = ctx.getResources();
-                    mAccount = acc;
-
-                    row.setTag(acc);
+        @Override
+        public void bind(AccountListItem item, @Nullable List<Object> payloads) {
+            LedgerAccount acc = item.toAccount()
+                                    .getAccount();
+
+            Change changes = new Change();
+            if (payloads != null) {
+                for (Object p : payloads) {
+                    if (p instanceof Change)
+                        changes.add((Change) p);
+                }
+            }
+//            debug("accounts",
+//                    String.format(Locale.US, "Binding '%s' to %s", acc.getName(), this));
 
-                    tvAccountName.setText(acc.getShortName());
+            Resources rm = b.getRoot()
+                            .getContext()
+                            .getResources();
 
-                    ConstraintLayout.LayoutParams lp =
-                            (ConstraintLayout.LayoutParams) tvAccountName.getLayoutParams();
-                    lp.setMarginStart(
-                            acc.getLevel() * rm.getDimensionPixelSize(R.dimen.thumb_row_height) /
-                            3);
+            if (changes.has(Change.NAME))
+                b.accountRowAccName.setText(acc.getShortName());
 
-                    if (acc.hasSubAccounts()) {
-                        expanderContainer.setVisibility(View.VISIBLE);
-                        expanderContainer.setRotation(acc.isExpanded() ? 0 : 180);
-                    }
-                    else {
-                        expanderContainer.setVisibility(View.GONE);
-                    }
+            if (changes.has(Change.LEVEL)) {
+                ConstraintLayout.LayoutParams lp =
+                        (ConstraintLayout.LayoutParams) b.flowWrapper.getLayoutParams();
+                lp.setMarginStart(
+                        acc.getLevel() * rm.getDimensionPixelSize(R.dimen.thumb_row_height) / 3);
+            }
 
-                    int amounts = acc.getAmountCount();
-                    if ((amounts > AMOUNT_LIMIT) && !acc.amountsExpanded()) {
-                        tvAccountAmounts.setText(acc.getAmountsString(AMOUNT_LIMIT));
-                        amountExpanderContainer.setVisibility(View.VISIBLE);
-                    }
-                    else {
-                        tvAccountAmounts.setText(acc.getAmountsString());
-                        amountExpanderContainer.setVisibility(View.GONE);
+            if (acc.hasSubAccounts()) {
+                b.accountExpanderContainer.setVisibility(View.VISIBLE);
+
+                if (changes.has(Change.EXPANDED)) {
+                    int wantedRotation = acc.isExpanded() ? 0 : 180;
+                    if (b.accountExpanderContainer.getRotation() != wantedRotation) {
+//                        Logger.debug("acc-ui",
+//                                String.format(Locale.ROOT, "Rotating %s to %d", acc.getName(),
+//                                        wantedRotation));
+                        b.accountExpanderContainer.animate()
+                                                  .rotation(wantedRotation);
                     }
-
-                    break;
-                case HEADER:
-                    setLastUpdateText(Data.lastAccountsUpdateText.get());
-                    break;
-                default:
-                    throw new IllegalStateException("Unexpected value: " + newType);
+                }
             }
-
-        }
-        void setLastUpdateText(String text) {
-            tvLastUpdate.setText(text);
-        }
-        private void initLastUpdateObserver() {
-            if (lastUpdateObserver != null)
-                return;
-
-            lastUpdateObserver = (o, arg) -> setLastUpdateText(Data.lastAccountsUpdateText.get());
-
-            Data.lastAccountsUpdateText.addObserver(lastUpdateObserver);
-        }
-        private void dropLastUpdateObserver() {
-            if (lastUpdateObserver == null)
-                return;
-
-            Data.lastAccountsUpdateText.deleteObserver(lastUpdateObserver);
-            lastUpdateObserver = null;
-        }
-        private void setType(AccountListItem.Type newType) {
-            if (newType == lastType)
-                return;
-
-            switch (newType) {
-                case ACCOUNT:
-                    row.setLongClickable(true);
-                    amountExpanderContainer.setVisibility(View.VISIBLE);
-                    vAccountNameLayout.setVisibility(View.VISIBLE);
-                    tvAccountAmounts.setVisibility(View.VISIBLE);
-                    lLastUpdate.setVisibility(View.GONE);
-                    dropLastUpdateObserver();
-                    break;
-                case HEADER:
-                    row.setLongClickable(false);
-                    tvAccountAmounts.setVisibility(View.GONE);
-                    amountExpanderContainer.setVisibility(View.GONE);
-                    vAccountNameLayout.setVisibility(View.GONE);
-                    lLastUpdate.setVisibility(View.VISIBLE);
-                    initLastUpdateObserver();
-                    break;
-                default:
-                    throw new IllegalStateException("Unexpected value: " + newType);
+            else {
+                b.accountExpanderContainer.setVisibility(View.GONE);
             }
 
-            lastType = newType;
+            if (changes.has(Change.EXPANDED_AMOUNTS)) {
+                int amounts = acc.getAmountCount();
+                if ((amounts > AMOUNT_LIMIT) && !acc.amountsExpanded()) {
+                    b.accountRowAccAmounts.setText(acc.getAmountsString(AMOUNT_LIMIT));
+                    b.accountRowAmountsExpanderContainer.setVisibility(View.VISIBLE);
+                }
+                else {
+                    b.accountRowAccAmounts.setText(acc.getAmountsString());
+                    b.accountRowAmountsExpanderContainer.setVisibility(View.GONE);
+                }
+            }
         }
     }
 }
index 5f4ffe57f2c4213188c526ceb311df6af03c6b54..807c16daf8540af877827e2729d0e848f733d3c3 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2020 Damyan Ivanov.
+ * Copyright © 2024 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
 
 package net.ktnx.mobileledger.ui.account_summary;
 
+import static net.ktnx.mobileledger.utils.Logger.debug;
+
 import android.content.Context;
 import android.os.Bundle;
 import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
 import android.view.View;
 import android.view.ViewGroup;
 
@@ -29,25 +34,34 @@ import androidx.lifecycle.ViewModelProvider;
 import androidx.recyclerview.widget.DividerItemDecoration;
 import androidx.recyclerview.widget.LinearLayoutManager;
 import androidx.recyclerview.widget.RecyclerView;
+import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
 
 import net.ktnx.mobileledger.R;
+import net.ktnx.mobileledger.async.GeneralBackgroundTasks;
+import net.ktnx.mobileledger.databinding.AccountSummaryFragmentBinding;
+import net.ktnx.mobileledger.db.AccountWithAmounts;
+import net.ktnx.mobileledger.db.DB;
+import net.ktnx.mobileledger.db.Profile;
 import net.ktnx.mobileledger.model.AccountListItem;
 import net.ktnx.mobileledger.model.Data;
+import net.ktnx.mobileledger.model.LedgerAccount;
+import net.ktnx.mobileledger.ui.FabManager;
 import net.ktnx.mobileledger.ui.MainModel;
 import net.ktnx.mobileledger.ui.MobileLedgerListFragment;
 import net.ktnx.mobileledger.ui.activity.MainActivity;
 import net.ktnx.mobileledger.utils.Colors;
-import net.ktnx.mobileledger.utils.Logger;
 
 import org.jetbrains.annotations.NotNull;
 
+import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
-import java.util.Locale;
-
-import static net.ktnx.mobileledger.utils.Logger.debug;
 
 public class AccountSummaryFragment extends MobileLedgerListFragment {
     public AccountSummaryAdapter modelAdapter;
+    private AccountSummaryFragmentBinding b;
+    private MenuItem menuShowZeroBalances;
+    private MainModel model;
     @Override
     public void onCreate(@Nullable Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
@@ -62,50 +76,146 @@ public class AccountSummaryFragment extends MobileLedgerListFragment {
     public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
                              @Nullable Bundle savedInstanceState) {
         debug("flow", "AccountSummaryFragment.onCreateView()");
-        return inflater.inflate(R.layout.account_summary_fragment, container, false);
+        b = AccountSummaryFragmentBinding.inflate(inflater, container, false);
+        return b.getRoot();
     }
-
     @Override
-
-    public void onActivityCreated(@Nullable Bundle savedInstanceState) {
+    public SwipeRefreshLayout getRefreshLayout() {
+        return b.accountSwipeRefreshLayout;
+    }
+    @Override
+    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
         debug("flow", "AccountSummaryFragment.onActivityCreated()");
-        super.onActivityCreated(savedInstanceState);
+        super.onViewCreated(view, savedInstanceState);
 
-        MainModel model = new ViewModelProvider(requireActivity()).get(MainModel.class);
+        model = new ViewModelProvider(requireActivity()).get(MainModel.class);
 
         Data.backgroundTasksRunning.observe(this.getViewLifecycleOwner(),
                 this::onBackgroundTaskRunningChanged);
 
-        modelAdapter = new AccountSummaryAdapter(model);
+        modelAdapter = new AccountSummaryAdapter();
         MainActivity mainActivity = getMainActivity();
 
-        root = mainActivity.findViewById(R.id.account_root);
         LinearLayoutManager llm = new LinearLayoutManager(mainActivity);
         llm.setOrientation(RecyclerView.VERTICAL);
-        root.setLayoutManager(llm);
-        root.setAdapter(modelAdapter);
+        b.accountRoot.setLayoutManager(llm);
+        b.accountRoot.setAdapter(modelAdapter);
         DividerItemDecoration did =
                 new DividerItemDecoration(mainActivity, DividerItemDecoration.VERTICAL);
-        root.addItemDecoration(did);
+        b.accountRoot.addItemDecoration(did);
 
         mainActivity.fabShouldShow();
 
-        manageFabOnScroll();
+        if (mainActivity instanceof FabManager.FabHandler)
+            FabManager.handle(mainActivity, b.accountRoot);
 
-        refreshLayout = mainActivity.findViewById(R.id.account_swipe_refresh_layout);
         Colors.themeWatch.observe(getViewLifecycleOwner(), this::themeChanged);
-        refreshLayout.setOnRefreshListener(() -> {
+        b.accountSwipeRefreshLayout.setOnRefreshListener(() -> {
             debug("ui", "refreshing accounts via swipe");
             model.scheduleTransactionListRetrieval();
         });
 
-        model.getDisplayedAccounts()
-             .observe(getViewLifecycleOwner(), this::onAccountsChanged);
+        Data.observeProfile(this, profile -> onProfileChanged(profile, Boolean.TRUE.equals(
+                model.getShowZeroBalanceAccounts()
+                     .getValue())));
+    }
+    @Override
+    public void onCreateOptionsMenu(@NotNull Menu menu, @NotNull MenuInflater inflater) {
+        inflater.inflate(R.menu.account_list, menu);
+
+        menuShowZeroBalances = menu.findItem(R.id.menu_account_list_show_zero_balances);
+        if ((menuShowZeroBalances == null))
+            throw new AssertionError();
+
+        menuShowZeroBalances.setOnMenuItemClickListener(menuItem -> {
+            model.getShowZeroBalanceAccounts()
+                 .setValue(Boolean.FALSE.equals(model.getShowZeroBalanceAccounts()
+                                                     .getValue()));
+            return true;
+        });
+
+        model.getShowZeroBalanceAccounts()
+             .observe(this, v -> {
+                 menuShowZeroBalances.setChecked(v);
+                 onProfileChanged(Data.getProfile(), v);
+             });
+
+        super.onCreateOptionsMenu(menu, inflater);
+    }
+    private void onProfileChanged(Profile profile, boolean showZeroBalanceAccounts) {
+        if (profile == null)
+            return;
+
+        DB.get()
+          .getAccountDAO()
+          .getAllWithAmounts(profile.getId(), showZeroBalanceAccounts)
+          .observe(getViewLifecycleOwner(), list -> GeneralBackgroundTasks.run(() -> {
+              List<AccountListItem> adapterList = new ArrayList<>();
+              adapterList.add(new AccountListItem.Header(Data.lastAccountsUpdateText));
+              HashMap<String, LedgerAccount> accMap = new HashMap<>();
+              for (AccountWithAmounts dbAcc : list) {
+                  LedgerAccount parent = null;
+                  String parentName = dbAcc.account.getParentName();
+                  if (parentName != null)
+                      parent = accMap.get(parentName);
+                  if (parent != null)
+                      parent.setHasSubAccounts(true);
+                  final LedgerAccount account = LedgerAccount.fromDBO(dbAcc, parent);
+                  if (account.isVisible())
+                      adapterList.add(new AccountListItem.Account(account));
+                  accMap.put(dbAcc.account.getName(), account);
+              }
+
+              if (!showZeroBalanceAccounts) {
+                  removeZeroAccounts(adapterList);
+              }
+              modelAdapter.setAccounts(adapterList);
+              Data.lastUpdateAccountCount.postValue(adapterList.size() - 1);
+          }));
     }
-    private void onAccountsChanged(List<AccountListItem> accounts) {
-        Logger.debug("async-acc",
-                String.format(Locale.US, "fragment: got new account list (%d items)",
-                        accounts.size()));
-        modelAdapter.setAccounts(accounts);
+    private void removeZeroAccounts(List<AccountListItem> list) {
+        boolean removed = true;
+
+        while (removed) {
+            AccountListItem last = null;
+            removed = false;
+            List<AccountListItem> newList = new ArrayList<>();
+
+            for (AccountListItem item : list) {
+                if (last == null) {
+                    last = item;
+                    continue;
+                }
+
+                if (!last.isAccount() || !last.toAccount()
+                                              .allAmountsAreZero() || last.toAccount()
+                                                                          .getAccount()
+                                                                          .isParentOf(
+                                                                                  item.toAccount()
+                                                                                      .getAccount()))
+                {
+                    newList.add(last);
+                }
+                else {
+                    removed = true;
+                }
+
+                last = item;
+            }
+
+            if (last != null) {
+                if (!last.isAccount() || !last.toAccount()
+                                              .allAmountsAreZero())
+                {
+                    newList.add(last);
+                }
+                else {
+                    removed = true;
+                }
+            }
+
+            list.clear();
+            list.addAll(newList);
+        }
     }
 }
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/activity/AsyncCrasher.java b/app/src/main/java/net/ktnx/mobileledger/ui/activity/AsyncCrasher.java
deleted file mode 100644 (file)
index ce02ff5..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-/*
- * Copyright © 2019 Damyan Ivanov.
- * This file is part of MoLe.
- * MoLe is free software: you can distribute it and/or modify it
- * under the term of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your opinion), any later version.
- *
- * MoLe is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License terms for details.
- *
- * You should have received a copy of the GNU General Public License
- * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package net.ktnx.mobileledger.ui.activity;
-
-import android.os.AsyncTask;
-
-class AsyncCrasher extends AsyncTask<Void, Void, Void> {
-    @Override
-    protected Void doInBackground(Void... voids) {
-        throw new RuntimeException("Simulated crash");
-    }
-}
index c66f43c489a98328556edd196dcdec6580b92852..e686d96fdb37233dd2a0ffcd372b6ebdf359ba96 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2020 Damyan Ivanov.
+ * Copyright © 2021 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
@@ -17,6 +17,7 @@
 
 package net.ktnx.mobileledger.ui.activity;
 
+import android.content.Context;
 import android.content.Intent;
 import android.content.pm.PackageInfo;
 import android.content.pm.ShortcutInfo;
@@ -30,38 +31,48 @@ import android.text.format.DateUtils;
 import android.util.Log;
 import android.view.View;
 import android.view.animation.AnimationUtils;
-import android.widget.LinearLayout;
-import android.widget.ProgressBar;
 import android.widget.TextView;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.appcompat.app.ActionBarDrawerToggle;
-import androidx.appcompat.widget.Toolbar;
+import androidx.appcompat.app.AlertDialog;
 import androidx.core.view.GravityCompat;
 import androidx.drawerlayout.widget.DrawerLayout;
 import androidx.fragment.app.Fragment;
-import androidx.fragment.app.FragmentManager;
-import androidx.fragment.app.FragmentPagerAdapter;
+import androidx.fragment.app.FragmentActivity;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
 import androidx.lifecycle.ViewModelProvider;
 import androidx.recyclerview.widget.LinearLayoutManager;
 import androidx.recyclerview.widget.RecyclerView;
-import androidx.viewpager.widget.ViewPager;
+import androidx.viewpager2.adapter.FragmentStateAdapter;
+import androidx.viewpager2.widget.ViewPager2;
 
-import com.google.android.material.floatingactionbutton.FloatingActionButton;
 import com.google.android.material.snackbar.Snackbar;
 
+import net.ktnx.mobileledger.BackupsActivity;
 import net.ktnx.mobileledger.R;
 import net.ktnx.mobileledger.async.RetrieveTransactionsTask;
+import net.ktnx.mobileledger.async.TransactionAccumulator;
+import net.ktnx.mobileledger.databinding.ActivityMainBinding;
+import net.ktnx.mobileledger.db.DB;
+import net.ktnx.mobileledger.db.Option;
+import net.ktnx.mobileledger.db.Profile;
+import net.ktnx.mobileledger.db.TransactionWithAccounts;
 import net.ktnx.mobileledger.model.Data;
-import net.ktnx.mobileledger.model.MobileLedgerProfile;
+import net.ktnx.mobileledger.model.LedgerTransaction;
+import net.ktnx.mobileledger.ui.FabManager;
 import net.ktnx.mobileledger.ui.MainModel;
 import net.ktnx.mobileledger.ui.account_summary.AccountSummaryFragment;
-import net.ktnx.mobileledger.ui.profiles.ProfileDetailFragment;
+import net.ktnx.mobileledger.ui.new_transaction.NewTransactionActivity;
+import net.ktnx.mobileledger.ui.profiles.ProfileDetailActivity;
 import net.ktnx.mobileledger.ui.profiles.ProfilesRecyclerViewAdapter;
+import net.ktnx.mobileledger.ui.templates.TemplatesActivity;
 import net.ktnx.mobileledger.ui.transaction_list.TransactionListFragment;
 import net.ktnx.mobileledger.utils.Colors;
 import net.ktnx.mobileledger.utils.Logger;
-import net.ktnx.mobileledger.utils.MLDB;
+import net.ktnx.mobileledger.utils.Misc;
 
 import org.jetbrains.annotations.NotNull;
 
@@ -75,37 +86,38 @@ import java.util.Objects;
  * TODO: reports
  *  */
 
-public class MainActivity extends ProfileThemedActivity {
+public class MainActivity extends ProfileThemedActivity implements FabManager.FabHandler {
+    public static final String TAG = "main-act";
     public static final String STATE_CURRENT_PAGE = "current_page";
     public static final String BUNDLE_SAVED_STATE = "bundle_savedState";
     public static final String STATE_ACC_FILTER = "account_filter";
-    DrawerLayout drawer;
-    private View profileListHeadMore, profileListHeadCancel, profileListHeadAddProfile;
-    private View bTransactionListCancelDownload;
+    private static final boolean FAB_HIDDEN = false;
+    private static final boolean FAB_SHOWN = true;
+    private ConverterThread converterThread = null;
     private SectionsPagerAdapter mSectionsPagerAdapter;
-    private ViewPager mViewPager;
-    private FloatingActionButton fab;
     private ProfilesRecyclerViewAdapter mProfileListAdapter;
     private int mCurrentPage;
     private boolean mBackMeansToAccountList = false;
-    private Toolbar mToolbar;
     private DrawerLayout.SimpleDrawerListener drawerListener;
     private ActionBarDrawerToggle barDrawerToggle;
-    private ViewPager.SimpleOnPageChangeListener pageChangeListener;
-    private MobileLedgerProfile profile;
+    private ViewPager2.OnPageChangeCallback pageChangeCallback;
+    private Profile profile;
     private MainModel mainModel;
+    private ActivityMainBinding b;
+    private int fabVerticalOffset;
+    private FabManager fabManager;
     @Override
     protected void onStart() {
         super.onStart();
 
-        Logger.debug("MainActivity", "onStart()");
+        Logger.debug(TAG, "onStart()");
 
-        mViewPager.setCurrentItem(mCurrentPage, false);
+        b.mainPager.setCurrentItem(mCurrentPage, false);
     }
     @Override
     protected void onSaveInstanceState(@NotNull Bundle outState) {
         super.onSaveInstanceState(outState);
-        outState.putInt(STATE_CURRENT_PAGE, mViewPager.getCurrentItem());
+        outState.putInt(STATE_CURRENT_PAGE, b.mainPager.getCurrentItem());
         if (mainModel.getAccountFilter()
                      .getValue() != null)
             outState.putString(STATE_ACC_FILTER, mainModel.getAccountFilter()
@@ -114,79 +126,57 @@ public class MainActivity extends ProfileThemedActivity {
     @Override
     protected void onDestroy() {
         mSectionsPagerAdapter = null;
-        RecyclerView root = findViewById(R.id.nav_profile_list);
-        if (root != null)
-            root.setAdapter(null);
-        if (drawer != null)
-            drawer.removeDrawerListener(drawerListener);
+        b.navProfileList.setAdapter(null);
+        b.drawerLayout.removeDrawerListener(drawerListener);
         drawerListener = null;
-        if (drawer != null)
-            drawer.removeDrawerListener(barDrawerToggle);
+        b.drawerLayout.removeDrawerListener(barDrawerToggle);
         barDrawerToggle = null;
-        if (mViewPager != null)
-            mViewPager.removeOnPageChangeListener(pageChangeListener);
-        pageChangeListener = null;
+        b.mainPager.unregisterOnPageChangeCallback(pageChangeCallback);
+        pageChangeCallback = null;
         super.onDestroy();
     }
     @Override
-    protected void setupProfileColors() {
-        final int profileColor = Data.retrieveCurrentThemeIdFromDb();
-        Colors.setupTheme(this, profileColor);
-        Colors.profileThemeId = profileColor;
-    }
-    @Override
     protected void onResume() {
         super.onResume();
         fabShouldShow();
     }
     @Override
     protected void onCreate(Bundle savedInstanceState) {
-        Logger.debug("MainActivity", "onCreate()/entry");
+        Logger.debug(TAG, "onCreate()/entry");
         super.onCreate(savedInstanceState);
-        Logger.debug("MainActivity", "onCreate()/after super");
-        setContentView(R.layout.activity_main);
+        Logger.debug(TAG, "onCreate()/after super");
+        b = ActivityMainBinding.inflate(getLayoutInflater());
+        setContentView(b.getRoot());
 
         mainModel = new ViewModelProvider(this).get(MainModel.class);
 
-        fab = findViewById(R.id.btn_add_transaction);
-        profileListHeadMore = findViewById(R.id.nav_profiles_start_edit);
-        profileListHeadCancel = findViewById(R.id.nav_profiles_cancel_edit);
-        LinearLayout profileListHeadMoreAndCancel =
-                findViewById(R.id.nav_profile_list_head_buttons);
-        profileListHeadAddProfile = findViewById(R.id.nav_new_profile_button);
-        drawer = findViewById(R.id.drawer_layout);
-        bTransactionListCancelDownload = findViewById(R.id.transaction_list_cancel_download);
-        mSectionsPagerAdapter = new SectionsPagerAdapter(getSupportFragmentManager());
-        mViewPager = findViewById(R.id.root_frame);
+        mSectionsPagerAdapter = new SectionsPagerAdapter(this);
 
         Bundle extra = getIntent().getBundleExtra(BUNDLE_SAVED_STATE);
         if (extra != null && savedInstanceState == null)
             savedInstanceState = extra;
 
 
-        mToolbar = findViewById(R.id.toolbar);
-        setSupportActionBar(mToolbar);
+        setSupportActionBar(b.toolbar);
 
         Data.observeProfile(this, this::onProfileChanged);
 
         Data.profiles.observe(this, this::onProfileListChanged);
+
         Data.backgroundTaskProgress.observe(this, this::onRetrieveProgress);
         Data.backgroundTasksRunning.observe(this, this::onRetrieveRunningChanged);
 
         if (barDrawerToggle == null) {
-            barDrawerToggle = new ActionBarDrawerToggle(this, drawer, mToolbar,
-                    R.string.navigation_drawer_open, R.string.navigation_drawer_close);
-            drawer.addDrawerListener(barDrawerToggle);
+            barDrawerToggle = new ActionBarDrawerToggle(this, b.drawerLayout, b.toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close);
+            b.drawerLayout.addDrawerListener(barDrawerToggle);
         }
         barDrawerToggle.syncState();
 
         try {
             PackageInfo pi = getApplicationContext().getPackageManager()
                                                     .getPackageInfo(getPackageName(), 0);
-            ((TextView) findViewById(R.id.nav_upper).findViewById(
-                    R.id.drawer_version_text)).setText(pi.versionName);
-            ((TextView) findViewById(R.id.no_profiles_layout).findViewById(
-                    R.id.drawer_version_text)).setText(pi.versionName);
+            ((TextView) b.navUpper.findViewById(R.id.drawer_version_text)).setText(pi.versionName);
+            ((TextView) b.noProfilesLayout.findViewById(R.id.drawer_version_text)).setText(pi.versionName);
         }
         catch (Exception e) {
             e.printStackTrace();
@@ -194,10 +184,11 @@ public class MainActivity extends ProfileThemedActivity {
 
         markDrawerItemCurrent(R.id.nav_account_summary);
 
-        mViewPager.setAdapter(mSectionsPagerAdapter);
+        b.mainPager.setAdapter(mSectionsPagerAdapter);
+        b.mainPager.setOffscreenPageLimit(1);
 
-        if (pageChangeListener == null) {
-            pageChangeListener = new ViewPager.SimpleOnPageChangeListener() {
+        if (pageChangeCallback == null) {
+            pageChangeCallback = new ViewPager2.OnPageChangeCallback() {
                 @Override
                 public void onPageSelected(int position) {
                     mCurrentPage = position;
@@ -209,14 +200,13 @@ public class MainActivity extends ProfileThemedActivity {
                             markDrawerItemCurrent(R.id.nav_latest_transactions);
                             break;
                         default:
-                            Log.e("MainActivity",
-                                    String.format("Unexpected page index %d", position));
+                            Log.e(TAG, String.format("Unexpected page index %d", position));
                     }
 
                     super.onPageSelected(position);
                 }
             };
-            mViewPager.addOnPageChangeListener(pageChangeListener);
+            b.mainPager.registerOnPageChangeCallback(pageChangeCallback);
         }
 
         mCurrentPage = 0;
@@ -229,46 +219,43 @@ public class MainActivity extends ProfileThemedActivity {
                      .setValue(savedInstanceState.getString(STATE_ACC_FILTER, null));
         }
 
-        findViewById(R.id.btn_no_profiles_add).setOnClickListener(
-                v -> startEditProfileActivity(null));
+        b.btnNoProfilesAdd.setOnClickListener(v -> ProfileDetailActivity.start(this, null));
+        b.btnRestore.setOnClickListener(v -> BackupsActivity.start(this));
 
-        findViewById(R.id.btn_add_transaction).setOnClickListener(this::fabNewTransactionClicked);
+        b.btnAddTransaction.setOnClickListener(this::fabNewTransactionClicked);
 
-        findViewById(R.id.nav_new_profile_button).setOnClickListener(
-                v -> startEditProfileActivity(null));
+        b.navNewProfileButton.setOnClickListener(v -> ProfileDetailActivity.start(this, null));
 
-        RecyclerView root = findViewById(R.id.nav_profile_list);
-        if (root == null)
-            throw new RuntimeException("Can't get hold on the transaction value view");
+        b.transactionListCancelDownload.setOnClickListener(this::onStopTransactionRefreshClick);
 
         if (mProfileListAdapter == null)
             mProfileListAdapter = new ProfilesRecyclerViewAdapter();
-        root.setAdapter(mProfileListAdapter);
+        b.navProfileList.setAdapter(mProfileListAdapter);
 
         mProfileListAdapter.editingProfiles.observe(this, newValue -> {
             if (newValue) {
-                profileListHeadMore.setVisibility(View.GONE);
-                profileListHeadCancel.setVisibility(View.VISIBLE);
-                profileListHeadAddProfile.setVisibility(View.VISIBLE);
-                if (drawer.isDrawerOpen(GravityCompat.START)) {
-                    profileListHeadMore.startAnimation(
+                b.navProfilesStartEdit.setVisibility(View.GONE);
+                b.navProfilesCancelEdit.setVisibility(View.VISIBLE);
+                b.navNewProfileButton.setVisibility(View.VISIBLE);
+                if (b.drawerLayout.isDrawerOpen(GravityCompat.START)) {
+                    b.navProfilesStartEdit.startAnimation(
                             AnimationUtils.loadAnimation(MainActivity.this, R.anim.fade_out));
-                    profileListHeadCancel.startAnimation(
+                    b.navProfilesCancelEdit.startAnimation(
                             AnimationUtils.loadAnimation(MainActivity.this, R.anim.fade_in));
-                    profileListHeadAddProfile.startAnimation(
+                    b.navNewProfileButton.startAnimation(
                             AnimationUtils.loadAnimation(MainActivity.this, R.anim.fade_in));
                 }
             }
             else {
-                profileListHeadCancel.setVisibility(View.GONE);
-                profileListHeadMore.setVisibility(View.VISIBLE);
-                profileListHeadAddProfile.setVisibility(View.GONE);
-                if (drawer.isDrawerOpen(GravityCompat.START)) {
-                    profileListHeadCancel.startAnimation(
+                b.navProfilesCancelEdit.setVisibility(View.GONE);
+                b.navProfilesStartEdit.setVisibility(View.VISIBLE);
+                b.navNewProfileButton.setVisibility(View.GONE);
+                if (b.drawerLayout.isDrawerOpen(GravityCompat.START)) {
+                    b.navProfilesCancelEdit.startAnimation(
                             AnimationUtils.loadAnimation(MainActivity.this, R.anim.fade_out));
-                    profileListHeadMore.startAnimation(
+                    b.navProfilesStartEdit.startAnimation(
                             AnimationUtils.loadAnimation(MainActivity.this, R.anim.fade_in));
-                    profileListHeadAddProfile.startAnimation(
+                    b.navNewProfileButton.startAnimation(
                             AnimationUtils.loadAnimation(MainActivity.this, R.anim.fade_out));
                 }
             }
@@ -276,21 +263,22 @@ public class MainActivity extends ProfileThemedActivity {
             mProfileListAdapter.notifyDataSetChanged();
         });
 
+        fabManager = new FabManager(b.btnAddTransaction);
+
         LinearLayoutManager llm = new LinearLayoutManager(this);
 
         llm.setOrientation(RecyclerView.VERTICAL);
-        root.setLayoutManager(llm);
+        b.navProfileList.setLayoutManager(llm);
 
-        profileListHeadMore.setOnClickListener((v) -> mProfileListAdapter.flipEditingProfiles());
-        profileListHeadCancel.setOnClickListener((v) -> mProfileListAdapter.flipEditingProfiles());
-        profileListHeadMoreAndCancel.setOnClickListener(
-                (v) -> mProfileListAdapter.flipEditingProfiles());
+        b.navProfilesStartEdit.setOnClickListener((v) -> mProfileListAdapter.flipEditingProfiles());
+        b.navProfilesCancelEdit.setOnClickListener((v) -> mProfileListAdapter.flipEditingProfiles());
+        b.navProfileListHeadButtons.setOnClickListener((v) -> mProfileListAdapter.flipEditingProfiles());
         if (drawerListener == null) {
             drawerListener = new DrawerLayout.SimpleDrawerListener() {
                 @Override
                 public void onDrawerSlide(@NonNull View drawerView, float slideOffset) {
                     if (slideOffset > 0.2)
-                        fabHide();
+                        fabManager.hideFab();
                 }
                 @Override
                 public void onDrawerClosed(View drawerView) {
@@ -305,17 +293,17 @@ public class MainActivity extends ProfileThemedActivity {
                     super.onDrawerOpened(drawerView);
                     mProfileListAdapter.setAnimationsEnabled(true);
                     Data.drawerOpen.setValue(true);
-                    fabHide();
+                    fabManager.hideFab();
                 }
             };
-            drawer.addDrawerListener(drawerListener);
+            b.drawerLayout.addDrawerListener(drawerListener);
         }
 
         Data.drawerOpen.observe(this, open -> {
             if (open)
-                drawer.open();
+                b.drawerLayout.open();
             else
-                drawer.close();
+                b.drawerLayout.close();
         });
 
         mainModel.getUpdateError()
@@ -323,7 +311,7 @@ public class MainActivity extends ProfileThemedActivity {
                      if (error == null)
                          return;
 
-                     Snackbar.make(mViewPager, error, Snackbar.LENGTH_LONG)
+                     Snackbar.make(b.mainPager, error, Snackbar.LENGTH_INDEFINITE)
                              .show();
                      mainModel.clearUpdateError();
                  });
@@ -331,6 +319,18 @@ public class MainActivity extends ProfileThemedActivity {
         Data.lastUpdateDate.observe(this, date -> refreshLastUpdateInfo());
         Data.lastUpdateTransactionCount.observe(this, date -> refreshLastUpdateInfo());
         Data.lastUpdateAccountCount.observe(this, date -> refreshLastUpdateInfo());
+        b.navAccountSummary.setOnClickListener(this::onAccountSummaryClicked);
+        b.navLatestTransactions.setOnClickListener(this::onLatestTransactionsClicked);
+        b.navPatterns.setOnClickListener(this::onPatternsClick);
+        b.navBackupRestore.setOnClickListener(this::onBackupRestoreClick);
+    }
+    private void onBackupRestoreClick(View view) {
+        Intent intent = new Intent(this, BackupsActivity.class);
+        startActivity(intent);
+    }
+    private void onPatternsClick(View view) {
+        Intent intent = new Intent(this, TemplatesActivity.class);
+        startActivity(intent);
     }
     private void scheduleDataRetrievalIfStale(long lastUpdate) {
         long now = new Date().getTime();
@@ -345,28 +345,31 @@ public class MainActivity extends ProfileThemedActivity {
             mainModel.scheduleTransactionListRetrieval();
         }
     }
-    private void createShortcuts(List<MobileLedgerProfile> list) {
+    private void createShortcuts(@NotNull List<Profile> list) {
         if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1)
             return;
 
         ShortcutManager sm = getSystemService(ShortcutManager.class);
         List<ShortcutInfo> shortcuts = new ArrayList<>();
         int i = 0;
-        for (MobileLedgerProfile p : list) {
+        for (Profile p : list) {
             if (shortcuts.size() >= sm.getMaxShortcutCountPerActivity())
                 break;
 
-            if (!p.isPostingPermitted())
+            if (!p.permitPosting())
                 continue;
 
             final ShortcutInfo.Builder builder =
-                    new ShortcutInfo.Builder(this, "new_transaction_" + p.getUuid());
+                    new ShortcutInfo.Builder(this, "new_transaction_" + p.getId());
             ShortcutInfo si = builder.setShortLabel(p.getName())
                                      .setIcon(Icon.createWithResource(this,
                                              R.drawable.thick_plus_icon))
                                      .setIntent(new Intent(Intent.ACTION_VIEW, null, this,
-                                             NewTransactionActivity.class).putExtra("profile_uuid",
-                                             p.getUuid()))
+                                             NewTransactionActivity.class).putExtra(
+                                             ProfileThemedActivity.PARAM_PROFILE_ID, p.getId())
+                                                                          .putExtra(
+                                                                                  ProfileThemedActivity.PARAM_THEME,
+                                                                                  p.getTheme()))
                                      .setRank(i)
                                      .build();
             shortcuts.add(si);
@@ -374,49 +377,60 @@ public class MainActivity extends ProfileThemedActivity {
         }
         sm.setDynamicShortcuts(shortcuts);
     }
-    private void onProfileListChanged(List<MobileLedgerProfile> newList) {
-        if ((newList == null) || newList.isEmpty()) {
-            findViewById(R.id.no_profiles_layout).setVisibility(View.VISIBLE);
-            findViewById(R.id.main_app_layout).setVisibility(View.GONE);
+    private void onProfileListChanged(@NotNull List<Profile> newList) {
+        createShortcuts(newList);
+
+        if (newList.isEmpty()) {
+            b.noProfilesLayout.setVisibility(View.VISIBLE);
+            b.mainAppLayout.setVisibility(View.GONE);
             return;
         }
 
-        findViewById(R.id.main_app_layout).setVisibility(View.VISIBLE);
-        findViewById(R.id.no_profiles_layout).setVisibility(View.GONE);
+        b.mainAppLayout.setVisibility(View.VISIBLE);
+        b.noProfilesLayout.setVisibility(View.GONE);
 
-        findViewById(R.id.nav_profile_list).setMinimumHeight(
+        b.navProfileList.setMinimumHeight(
                 (int) (getResources().getDimension(R.dimen.thumb_row_height) * newList.size()));
 
         Logger.debug("profiles", "profile list changed");
-        mProfileListAdapter.notifyDataSetChanged();
+        mProfileListAdapter.setProfileList(newList);
+
+        final Profile currentProfile = Data.getProfile();
+        Profile replacementProfile = null;
+        if (currentProfile != null) {
+            for (Profile p : newList) {
+                if (p.getId() == currentProfile.getId()) {
+                    replacementProfile = p;
+                    break;
+                }
+            }
+        }
 
-        createShortcuts(newList);
+        if (replacementProfile == null) {
+            Logger.debug(TAG, "Switching profile because the current is no longer available");
+            Data.setCurrentProfile(newList.get(0));
+        }
+        else {
+            Data.setCurrentProfile(replacementProfile);
+        }
     }
     /**
      * called when the current profile has changed
      */
-    private void onProfileChanged(MobileLedgerProfile profile) {
-        if (this.profile == null) {
-            if (profile == null)
-                return;
-        }
-        else {
-            if (this.profile.equals(profile))
+    private void onProfileChanged(@Nullable Profile newProfile) {
+        if (this.profile != null) {
+            if (this.profile.equals(newProfile))
                 return;
         }
 
-        boolean haveProfile = profile != null;
+        boolean haveProfile = newProfile != null;
 
         if (haveProfile)
-            setTitle(profile.getName());
+            setTitle(newProfile.getName());
         else
             setTitle(R.string.app_name);
 
-        mainModel.setProfile(profile);
-
-        this.profile = profile;
-
-        int newProfileTheme = haveProfile ? profile.getThemeHue() : -1;
+        int newProfileTheme = haveProfile ? newProfile.getTheme() : Colors.DEFAULT_HUE_DEG;
         if (newProfileTheme != Colors.profileThemeId) {
             Logger.debug("profiles",
                     String.format(Locale.ENGLISH, "profile theme %d → %d", Colors.profileThemeId,
@@ -428,34 +442,73 @@ public class MainActivity extends ProfileThemedActivity {
             return;
         }
 
-        findViewById(R.id.no_profiles_layout).setVisibility(haveProfile ? View.GONE : View.VISIBLE);
-        findViewById(R.id.pager_layout).setVisibility(haveProfile ? View.VISIBLE : View.VISIBLE);
+        final boolean sameProfileId = (newProfile != null) && (this.profile != null) &&
+                                      this.profile.getId() == newProfile.getId();
 
-        mProfileListAdapter.notifyDataSetChanged();
+        this.profile = newProfile;
 
-        mainModel.clearAccounts();
-        mainModel.clearTransactions();
+        b.noProfilesLayout.setVisibility(haveProfile ? View.GONE : View.VISIBLE);
+        b.pagerLayout.setVisibility(haveProfile ? View.VISIBLE : View.VISIBLE);
 
-        if (haveProfile) {
-            mainModel.scheduleAccountListReload();
-            Logger.debug("transactions", "requesting list reload");
-            mainModel.scheduleTransactionListReload();
+        mProfileListAdapter.notifyDataSetChanged();
 
-            if (profile.isPostingPermitted()) {
-                mToolbar.setSubtitle(null);
-                fab.show();
+        if (haveProfile) {
+            if (newProfile.permitPosting()) {
+                b.toolbar.setSubtitle(null);
+                b.btnAddTransaction.show();
             }
             else {
-                mToolbar.setSubtitle(R.string.profile_subtitle_read_only);
-                fab.hide();
+                b.toolbar.setSubtitle(R.string.profile_subtitle_read_only);
+                b.btnAddTransaction.hide();
             }
         }
         else {
-            mToolbar.setSubtitle(null);
-            fab.hide();
+            b.toolbar.setSubtitle(null);
+            b.btnAddTransaction.hide();
         }
 
         updateLastUpdateTextFromDB();
+
+        if (sameProfileId) {
+            Logger.debug(TAG, String.format(Locale.ROOT, "Short-cut profile 'changed' to %d",
+                    newProfile.getId()));
+            return;
+        }
+
+        mainModel.getAccountFilter()
+                 .observe(this, this::onAccountFilterChanged);
+
+        mainModel.stopTransactionsRetrieval();
+        mainModel.clearTransactions();
+    }
+    private void onAccountFilterChanged(String accFilter) {
+        Logger.debug(TAG, "account filter changed, reloading transactions");
+//                     mainModel.scheduleTransactionListReload();
+        LiveData<List<TransactionWithAccounts>> transactions =
+                new MutableLiveData<>(new ArrayList<>());
+        if (profile != null) {
+            if (accFilter == null || accFilter.isEmpty()) {
+                transactions = DB.get()
+                                 .getTransactionDAO()
+                                 .getAllWithAccounts(profile.getId());
+            }
+            else {
+                transactions = DB.get()
+                                 .getTransactionDAO()
+                                 .getAllWithAccountsFiltered(profile.getId(), accFilter);
+            }
+        }
+
+        transactions.observe(this, list -> {
+            Logger.debug(TAG,
+                    String.format(Locale.ROOT, "got transaction list from DB (%d transactions)",
+                            list.size()));
+
+            if (converterThread != null)
+                converterThread.interrupt();
+            converterThread = new ConverterThread(mainModel, list, accFilter);
+            converterThread.start();
+        });
     }
     private void profileThemeChanged() {
         // un-hook all observed LiveData
@@ -465,55 +518,46 @@ public class MainActivity extends ProfileThemedActivity {
         Data.lastUpdateAccountCount.removeObservers(this);
         Data.lastUpdateDate.removeObservers(this);
 
+        Logger.debug(TAG, "profileThemeChanged(): recreating activity");
         recreate();
     }
-    public void startEditProfileActivity(MobileLedgerProfile profile) {
-        Intent intent = new Intent(this, ProfileDetailActivity.class);
-        Bundle args = new Bundle();
-        if (profile != null) {
-            int index = Data.getProfileIndex(profile);
-            if (index != -1)
-                intent.putExtra(ProfileDetailFragment.ARG_ITEM_ID, index);
-        }
-        intent.putExtras(args);
-        startActivity(intent, args);
-    }
     public void fabNewTransactionClicked(View view) {
         Intent intent = new Intent(this, NewTransactionActivity.class);
+        intent.putExtra(ProfileThemedActivity.PARAM_PROFILE_ID, profile.getId());
+        intent.putExtra(ProfileThemedActivity.PARAM_THEME, profile.getTheme());
         startActivity(intent);
         overridePendingTransition(R.anim.slide_in_up, R.anim.dummy);
     }
     public void markDrawerItemCurrent(int id) {
-        TextView item = drawer.findViewById(id);
+        TextView item = b.drawerLayout.findViewById(id);
         item.setBackgroundColor(Colors.tableRowDarkBG);
 
-        LinearLayout actions = drawer.findViewById(R.id.nav_actions);
-        for (int i = 0; i < actions.getChildCount(); i++) {
-            View view = actions.getChildAt(i);
+        for (int i = 0; i < b.navActions.getChildCount(); i++) {
+            View view = b.navActions.getChildAt(i);
             if (view.getId() != id) {
                 view.setBackgroundColor(Color.TRANSPARENT);
             }
         }
     }
     public void onAccountSummaryClicked(View view) {
-        drawer.closeDrawers();
+        b.drawerLayout.closeDrawers();
 
         showAccountSummaryFragment();
     }
     private void showAccountSummaryFragment() {
-        mViewPager.setCurrentItem(0, true);
+        b.mainPager.setCurrentItem(0, true);
         mainModel.getAccountFilter()
                  .setValue(null);
     }
     public void onLatestTransactionsClicked(View view) {
-        drawer.closeDrawers();
+        b.drawerLayout.closeDrawers();
 
         showTransactionsFragment(null);
     }
     public void showTransactionsFragment(String accName) {
         mainModel.getAccountFilter()
                  .setValue(accName);
-        mViewPager.setCurrentItem(1, true);
+        b.mainPager.setCurrentItem(1, true);
     }
     public void showAccountTransactions(String accountName) {
         mBackMeansToAccountList = true;
@@ -521,19 +565,18 @@ public class MainActivity extends ProfileThemedActivity {
     }
     @Override
     public void onBackPressed() {
-        DrawerLayout drawer = findViewById(R.id.drawer_layout);
-        if (drawer.isDrawerOpen(GravityCompat.START)) {
-            drawer.closeDrawer(GravityCompat.START);
+        if (b.drawerLayout.isDrawerOpen(GravityCompat.START)) {
+            b.drawerLayout.closeDrawer(GravityCompat.START);
         }
         else {
-            if (mBackMeansToAccountList && (mViewPager.getCurrentItem() == 1)) {
+            if (mBackMeansToAccountList && (b.mainPager.getCurrentItem() == 1)) {
                 mainModel.getAccountFilter()
                          .setValue(null);
                 showAccountSummaryFragment();
                 mBackMeansToAccountList = false;
             }
             else {
-                Logger.debug("fragments", String.format(Locale.ENGLISH, "manager stack: %d",
+                Logger.debug(TAG, String.format(Locale.ENGLISH, "manager stack: %d",
                         getSupportFragmentManager().getBackStackEntryCount()));
 
                 super.onBackPressed();
@@ -544,18 +587,30 @@ public class MainActivity extends ProfileThemedActivity {
         if (profile == null)
             return;
 
-        long lastUpdate = profile.getLongOption(MLDB.OPT_LAST_SCRAPE, 0L);
-
-        Logger.debug("transactions", String.format(Locale.ENGLISH, "Last update = %d", lastUpdate));
-        if (lastUpdate == 0) {
-            Data.lastUpdateDate.postValue(null);
-        }
-        else {
-            Data.lastUpdateDate.postValue(new Date(lastUpdate));
-        }
-
-        scheduleDataRetrievalIfStale(lastUpdate);
-
+        DB.get()
+          .getOptionDAO()
+          .load(profile.getId(), Option.OPT_LAST_SCRAPE)
+          .observe(this, opt -> {
+              long lastUpdate = 0;
+              if (opt != null) {
+                  try {
+                      lastUpdate = Long.parseLong(opt.getValue());
+                  }
+                  catch (NumberFormatException ex) {
+                      Logger.debug(TAG, String.format("Error parsing '%s' as long", opt.getValue()),
+                              ex);
+                  }
+              }
+
+              if (lastUpdate == 0) {
+                  Data.lastUpdateDate.postValue(null);
+              }
+              else {
+                  Data.lastUpdateDate.postValue(new Date(lastUpdate));
+              }
+
+              scheduleDataRetrievalIfStale(lastUpdate);
+          });
     }
     private void refreshLastUpdateInfo() {
         final int formatFlags = DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR |
@@ -567,59 +622,67 @@ public class MainActivity extends ProfileThemedActivity {
         Integer transactionCount = Data.lastUpdateTransactionCount.getValue();
         Date lastUpdate = Data.lastUpdateDate.getValue();
         if (lastUpdate == null) {
-            Data.lastTransactionsUpdateText.set("----");
-            Data.lastAccountsUpdateText.set("----");
+            Data.lastTransactionsUpdateText.setValue("----");
+            Data.lastAccountsUpdateText.setValue("----");
         }
         else {
-            Data.lastTransactionsUpdateText.set(
+            Data.lastTransactionsUpdateText.setValue(
                     String.format(Objects.requireNonNull(Data.locale.getValue()),
                             templateForTransactions,
                             transactionCount == null ? 0 : transactionCount,
                             DateUtils.formatDateTime(this, lastUpdate.getTime(), formatFlags)));
-            Data.lastAccountsUpdateText.set(
+            Data.lastAccountsUpdateText.setValue(
                     String.format(Objects.requireNonNull(Data.locale.getValue()),
                             templateForAccounts, accountCount == null ? 0 : accountCount,
                             DateUtils.formatDateTime(this, lastUpdate.getTime(), formatFlags)));
         }
     }
     public void onStopTransactionRefreshClick(View view) {
-        Logger.debug("interactive", "Cancelling transactions refresh");
+        Logger.debug(TAG, "Cancelling transactions refresh");
         mainModel.stopTransactionsRetrieval();
-        bTransactionListCancelDownload.setEnabled(false);
+        b.transactionListCancelDownload.setEnabled(false);
     }
     public void onRetrieveRunningChanged(Boolean running) {
-        final View progressLayout = findViewById(R.id.transaction_progress_layout);
         if (running) {
-            ProgressBar progressBar = findViewById(R.id.transaction_list_progress_bar);
-            bTransactionListCancelDownload.setEnabled(true);
+            b.transactionListCancelDownload.setEnabled(true);
             ColorStateList csl = Colors.getColorStateList();
-            progressBar.setIndeterminateTintList(csl);
-            progressBar.setProgressTintList(csl);
-            progressBar.setIndeterminate(true);
+            b.transactionListProgressBar.setIndeterminateTintList(csl);
+            b.transactionListProgressBar.setProgressTintList(csl);
+            b.transactionListProgressBar.setIndeterminate(true);
             if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
-                progressBar.setProgress(0, false);
+                b.transactionListProgressBar.setProgress(0, false);
             }
             else {
-                progressBar.setProgress(0);
+                b.transactionListProgressBar.setProgress(0);
             }
-            progressLayout.setVisibility(View.VISIBLE);
+            b.transactionProgressLayout.setVisibility(View.VISIBLE);
         }
         else {
-            progressLayout.setVisibility(View.GONE);
+            b.transactionProgressLayout.setVisibility(View.GONE);
         }
     }
-    public void onRetrieveProgress(RetrieveTransactionsTask.Progress progress) {
-        ProgressBar progressBar = findViewById(R.id.transaction_list_progress_bar);
-
-        if (progress.getState() == RetrieveTransactionsTask.ProgressState.FINISHED) {
-            Logger.debug("progress", "Done");
-            findViewById(R.id.transaction_progress_layout).setVisibility(View.GONE);
+    public void onRetrieveProgress(@Nullable RetrieveTransactionsTask.Progress progress) {
+        if (progress == null ||
+            progress.getState() == RetrieveTransactionsTask.ProgressState.FINISHED)
+        {
+            Logger.debug(TAG, "progress: Done");
+            b.transactionProgressLayout.setVisibility(View.GONE);
 
             mainModel.transactionRetrievalDone();
 
-            if (progress.getError() != null) {
-                Snackbar.make(mViewPager, progress.getError(), Snackbar.LENGTH_LONG)
-                        .show();
+            String error = (progress == null) ? null : progress.getError();
+            if (error != null) {
+                if (error.equals(RetrieveTransactionsTask.Result.ERR_JSON_PARSER_ERROR))
+                    error = getResources().getString(R.string.err_json_parser_error);
+
+                AlertDialog.Builder builder = new AlertDialog.Builder(this);
+                builder.setMessage(error);
+                builder.setPositiveButton(R.string.btn_profile_options, (dialog, which) -> {
+                    Logger.debug(TAG, "will start profile editor");
+                    ProfileDetailActivity.start(this, profile);
+                });
+                builder.create()
+                       .show();
                 return;
             }
 
@@ -627,7 +690,7 @@ public class MainActivity extends ProfileThemedActivity {
         }
 
 
-        bTransactionListCancelDownload.setEnabled(true);
+        b.transactionListCancelDownload.setEnabled(true);
 //        ColorStateList csl = Colors.getColorStateList();
 //        progressBar.setIndeterminateTintList(csl);
 //        progressBar.setProgressTintList(csl);
@@ -635,52 +698,57 @@ public class MainActivity extends ProfileThemedActivity {
 //            progressBar.setProgress(0, false);
 //        else
 //            progressBar.setProgress(0);
-        findViewById(R.id.transaction_progress_layout).setVisibility(View.VISIBLE);
+        b.transactionProgressLayout.setVisibility(View.VISIBLE);
 
         if (progress.isIndeterminate() || (progress.getTotal() <= 0)) {
-            progressBar.setIndeterminate(true);
-            Logger.debug("progress", "indeterminate");
+            b.transactionListProgressBar.setIndeterminate(true);
+            Logger.debug(TAG, "progress: indeterminate");
         }
         else {
-            if (progressBar.isIndeterminate()) {
-                progressBar.setIndeterminate(false);
+            if (b.transactionListProgressBar.isIndeterminate()) {
+                b.transactionListProgressBar.setIndeterminate(false);
             }
-//            Logger.debug("progress",
-//                    String.format(Locale.US, "%d/%d", progress.getProgress(), progress.getTotal
+//            Logger.debug(TAG,
+//                    String.format(Locale.US, "progress: %d/%d", progress.getProgress(),
+//                    progress.getTotal
 //                    ()));
-            progressBar.setMax(progress.getTotal());
+            b.transactionListProgressBar.setMax(progress.getTotal());
             // for some reason animation doesn't work - no progress is shown (stick at 0)
             // on lineageOS 14.1 (Nougat, 7.1.2)
             if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
-                progressBar.setProgress(progress.getProgress(), false);
+                b.transactionListProgressBar.setProgress(progress.getProgress(), false);
             else
-                progressBar.setProgress(progress.getProgress());
+                b.transactionListProgressBar.setProgress(progress.getProgress());
         }
     }
     public void fabShouldShow() {
-        if ((profile != null) && profile.isPostingPermitted() && !drawer.isOpen())
-            fab.show();
-        else
-            fabHide();
+        if ((profile != null) && profile.permitPosting() && !b.drawerLayout.isOpen())
+            fabManager.showFab();
     }
-    public void fabHide() {
-        fab.hide();
+    @Override
+    public Context getContext() {
+        return this;
     }
+    @Override
+    public void showManagedFab() {
+        fabShouldShow();
+    }
+    @Override
+    public void hideManagedFab() {
+        fabManager.hideFab();
+    }
+    public static class SectionsPagerAdapter extends FragmentStateAdapter {
 
-    public static class SectionsPagerAdapter extends FragmentPagerAdapter {
-
-        SectionsPagerAdapter(FragmentManager fm) {
-            super(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT);
+        public SectionsPagerAdapter(@NonNull FragmentActivity fragmentActivity) {
+            super(fragmentActivity);
         }
-
         @NotNull
         @Override
-        public Fragment getItem(int position) {
-            Logger.debug("main",
-                    String.format(Locale.ENGLISH, "Switching to fragment %d", position));
+        public Fragment createFragment(int position) {
+            Logger.debug(TAG, String.format(Locale.ENGLISH, "Switching to fragment %d", position));
             switch (position) {
                 case 0:
-//                    debug("flow", "Creating account summary fragment");
+//                    debug(TAG, "Creating account summary fragment");
                     return new AccountSummaryFragment();
                 case 1:
                     return new TransactionListFragment();
@@ -691,8 +759,41 @@ public class MainActivity extends ProfileThemedActivity {
         }
 
         @Override
-        public int getCount() {
+        public int getItemCount() {
             return 2;
         }
     }
+
+    static private class ConverterThread extends Thread {
+        private final List<TransactionWithAccounts> list;
+        private final MainModel model;
+        private final String accFilter;
+        public ConverterThread(@NonNull MainModel model,
+                               @NonNull List<TransactionWithAccounts> list, String accFilter) {
+            this.model = model;
+            this.list = list;
+            this.accFilter = accFilter;
+        }
+        @Override
+        public void run() {
+            TransactionAccumulator accumulator = new TransactionAccumulator(accFilter, accFilter);
+
+            for (TransactionWithAccounts tr : list) {
+                if (isInterrupted()) {
+                    Logger.debug(TAG, "ConverterThread bailing out on interrupt");
+                    return;
+                }
+                accumulator.put(new LedgerTransaction(tr));
+            }
+
+            if (isInterrupted()) {
+                Logger.debug(TAG, "ConverterThread bailing out on interrupt");
+                return;
+            }
+
+            Logger.debug(TAG, "ConverterThread publishing results");
+
+            Misc.onMainThread(() -> accumulator.publishResults(model));
+        }
+    }
 }
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionActivity.java b/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionActivity.java
deleted file mode 100644 (file)
index 7499641..0000000
+++ /dev/null
@@ -1,150 +0,0 @@
-/*
- * Copyright © 2020 Damyan Ivanov.
- * This file is part of MoLe.
- * MoLe is free software: you can distribute it and/or modify it
- * under the term of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your opinion), any later version.
- *
- * MoLe is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License terms for details.
- *
- * You should have received a copy of the GNU General Public License
- * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package net.ktnx.mobileledger.ui.activity;
-
-import android.os.Bundle;
-import android.util.TypedValue;
-import android.view.Menu;
-import android.view.MenuItem;
-import android.view.View;
-
-import androidx.appcompat.widget.Toolbar;
-import androidx.lifecycle.ViewModelProvider;
-import androidx.navigation.NavController;
-import androidx.navigation.fragment.NavHostFragment;
-
-import net.ktnx.mobileledger.BuildConfig;
-import net.ktnx.mobileledger.R;
-import net.ktnx.mobileledger.async.SendTransactionTask;
-import net.ktnx.mobileledger.async.TaskCallback;
-import net.ktnx.mobileledger.model.Data;
-import net.ktnx.mobileledger.model.LedgerTransaction;
-
-import java.util.Objects;
-
-import static net.ktnx.mobileledger.utils.Logger.debug;
-
-public class NewTransactionActivity extends ProfileThemedActivity implements TaskCallback,
-        NewTransactionFragment.OnNewTransactionFragmentInteractionListener {
-    private NavController navController;
-    private NewTransactionModel model;
-    @Override
-    protected void onCreate(Bundle savedInstanceState) {
-        super.onCreate(savedInstanceState);
-
-        setContentView(R.layout.activity_new_transaction);
-        Toolbar toolbar = findViewById(R.id.toolbar);
-        setSupportActionBar(toolbar);
-        Data.observeProfile(this,
-                mobileLedgerProfile -> toolbar.setSubtitle(mobileLedgerProfile.getName()));
-
-        NavHostFragment navHostFragment = (NavHostFragment) Objects.requireNonNull(
-                getSupportFragmentManager().findFragmentById(R.id.new_transaction_nav));
-        navController = navHostFragment.getNavController();
-
-        Objects.requireNonNull(getSupportActionBar())
-               .setDisplayHomeAsUpEnabled(true);
-
-        model = new ViewModelProvider(this).get(NewTransactionModel.class);
-    }
-    @Override
-    protected void initProfile() {
-        String profileUUID = getIntent().getStringExtra("profile_uuid");
-
-        if (profileUUID != null) {
-            mProfile = Data.getProfile(profileUUID);
-            if (mProfile == null)
-                finish();
-            Data.setCurrentProfile(mProfile);
-        }
-        else
-            super.initProfile();
-    }
-    @Override
-    public void finish() {
-        super.finish();
-        overridePendingTransition(R.anim.dummy, R.anim.slide_out_down);
-    }
-    @Override
-    public boolean onOptionsItemSelected(MenuItem item) {
-        if (item.getItemId() == android.R.id.home) {
-            finish();
-            return true;
-        }
-        return super.onOptionsItemSelected(item);
-    }
-    public void onTransactionSave(LedgerTransaction tr) {
-        navController.navigate(R.id.action_newTransactionFragment_to_newTransactionSavingFragment);
-        try {
-
-            SendTransactionTask saver =
-                    new SendTransactionTask(this, mProfile, model.getSimulateSave());
-            saver.execute(tr);
-        }
-        catch (Exception e) {
-            debug("new-transaction", "Unknown error", e);
-
-            Bundle b = new Bundle();
-            b.putString("error", "unknown error");
-            navController.navigate(R.id.newTransactionFragment, b);
-        }
-    }
-    public void simulateCrash(MenuItem item) {
-        debug("crash", "Will crash intentionally");
-        new AsyncCrasher().execute();
-    }
-    public boolean onCreateOptionsMenu(Menu menu) {
-        // Inflate the menu; this adds items to the action bar if it is present.
-        getMenuInflater().inflate(R.menu.new_transaction, menu);
-
-        if (BuildConfig.DEBUG) {
-            menu.findItem(R.id.action_simulate_crash)
-                .setVisible(true);
-            menu.findItem(R.id.action_simulate_save)
-                .setVisible(true);
-        }
-
-        model.observeSimulateSave(this, state -> {
-            menu.findItem(R.id.action_simulate_save)
-                .setChecked(state);
-            findViewById(R.id.simulationLabel).setVisibility(state ? View.VISIBLE : View.GONE);
-        });
-
-        return true;
-    }
-
-
-    public int dp2px(float dp) {
-        return Math.round(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp,
-                getResources().getDisplayMetrics()));
-    }
-    @Override
-    public void done(String error) {
-        Bundle b = new Bundle();
-        if (error != null) {
-            b.putString("error", error);
-            navController.navigate(R.id.action_newTransactionSavingFragment_Failure, b);
-        }
-        else
-            navController.navigate(R.id.action_newTransactionSavingFragment_Success, b);
-    }
-    public void toggleSimulateSave(MenuItem item) {
-        model.toggleSimulateSave();
-    }
-
-}
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionFragment.java b/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionFragment.java
deleted file mode 100644 (file)
index 05b272e..0000000
+++ /dev/null
@@ -1,262 +0,0 @@
-/*
- * Copyright © 2020 Damyan Ivanov.
- * This file is part of MoLe.
- * MoLe is free software: you can distribute it and/or modify it
- * under the term of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your opinion), any later version.
- *
- * MoLe is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License terms for details.
- *
- * You should have received a copy of the GNU General Public License
- * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package net.ktnx.mobileledger.ui.activity;
-
-import android.content.Context;
-import android.os.Bundle;
-import android.renderscript.RSInvalidStateException;
-import android.view.LayoutInflater;
-import android.view.Menu;
-import android.view.MenuInflater;
-import android.view.MenuItem;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.ProgressBar;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.fragment.app.Fragment;
-import androidx.fragment.app.FragmentActivity;
-import androidx.lifecycle.ViewModelProvider;
-import androidx.recyclerview.widget.LinearLayoutManager;
-import androidx.recyclerview.widget.RecyclerView;
-
-import com.google.android.material.floatingactionbutton.FloatingActionButton;
-import com.google.android.material.snackbar.Snackbar;
-
-import net.ktnx.mobileledger.R;
-import net.ktnx.mobileledger.model.Data;
-import net.ktnx.mobileledger.model.LedgerTransaction;
-import net.ktnx.mobileledger.model.LedgerTransactionAccount;
-import net.ktnx.mobileledger.model.MobileLedgerProfile;
-import net.ktnx.mobileledger.utils.Logger;
-import net.ktnx.mobileledger.utils.Misc;
-import net.ktnx.mobileledger.utils.SimpleDate;
-
-import org.jetbrains.annotations.NotNull;
-
-/**
- * A simple {@link Fragment} subclass.
- * Activities that contain this fragment must implement the
- * {@link OnNewTransactionFragmentInteractionListener} interface
- * to handle interaction events.
- */
-
-// TODO: offer to undo account remove-on-swipe
-// TODO: transaction-level comment
-
-public class NewTransactionFragment extends Fragment {
-    private NewTransactionItemsAdapter listAdapter;
-    private NewTransactionModel viewModel;
-    private FloatingActionButton fab;
-    private OnNewTransactionFragmentInteractionListener mListener;
-    private MobileLedgerProfile mProfile;
-    public NewTransactionFragment() {
-        // Required empty public constructor
-        setHasOptionsMenu(true);
-    }
-    @Override
-    public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
-        super.onCreateOptionsMenu(menu, inflater);
-        final FragmentActivity activity = getActivity();
-
-        inflater.inflate(R.menu.new_transaction_fragment, menu);
-        menu.findItem(R.id.action_reset_new_transaction_activity)
-            .setOnMenuItemClickListener(item -> {
-                listAdapter.reset();
-                return true;
-            });
-
-        final MenuItem toggleCurrencyItem = menu.findItem(R.id.toggle_currency);
-        toggleCurrencyItem.setOnMenuItemClickListener(item -> {
-            viewModel.toggleCurrencyVisible();
-            return true;
-        });
-        if (activity != null)
-            viewModel.showCurrency.observe(activity, toggleCurrencyItem::setChecked);
-
-        final MenuItem toggleCommentsItem = menu.findItem(R.id.toggle_comments);
-        toggleCommentsItem.setOnMenuItemClickListener(item -> {
-            viewModel.toggleShowComments();
-            return true;
-        });
-        if (activity != null)
-            viewModel.showComments.observe(activity, toggleCommentsItem::setChecked);
-    }
-    @Override
-    public View onCreateView(LayoutInflater inflater, ViewGroup container,
-                             Bundle savedInstanceState) {
-        // Inflate the layout for this fragment
-        return inflater.inflate(R.layout.fragment_new_transaction, container, false);
-    }
-
-    @Override
-    public void onActivityCreated(@Nullable Bundle savedInstanceState) {
-        super.onActivityCreated(savedInstanceState);
-        FragmentActivity activity = getActivity();
-        if (activity == null)
-            throw new RSInvalidStateException(
-                    "getActivity() returned null within onActivityCreated()");
-
-        viewModel = new ViewModelProvider(activity).get(NewTransactionModel.class);
-        viewModel.observeDataProfile(this);
-        mProfile = Data.getProfile();
-        listAdapter = new NewTransactionItemsAdapter(viewModel, mProfile);
-
-        RecyclerView list = activity.findViewById(R.id.new_transaction_accounts);
-        list.setAdapter(listAdapter);
-        list.setLayoutManager(new LinearLayoutManager(activity));
-
-        Data.observeProfile(getViewLifecycleOwner(), profile -> {
-            mProfile = profile;
-            listAdapter.setProfile(profile);
-        });
-        listAdapter.notifyDataSetChanged();
-        viewModel.isSubmittable()
-                 .observe(getViewLifecycleOwner(), isSubmittable -> {
-                     if (isSubmittable) {
-                         if (fab != null) {
-                             fab.show();
-                         }
-                     }
-                     else {
-                         if (fab != null) {
-                             fab.hide();
-                         }
-                     }
-                 });
-//        viewModel.checkTransactionSubmittable(listAdapter);
-
-        fab = activity.findViewById(R.id.fab);
-        fab.setOnClickListener(v -> onFabPressed());
-
-        boolean keep = false;
-
-        Bundle args = getArguments();
-        if (args != null) {
-            String error = args.getString("error");
-            if (error != null) {
-                Logger.debug("new-trans-f", String.format("Got error: %s", error));
-                Snackbar.make(list, error, Snackbar.LENGTH_LONG)
-                        .show();
-                keep = true;
-            }
-        }
-
-        int focused = 0;
-        if (savedInstanceState != null) {
-            keep |= savedInstanceState.getBoolean("keep", true);
-            focused = savedInstanceState.getInt("focused", 0);
-        }
-
-        if (!keep)
-            viewModel.reset();
-        else {
-            viewModel.setFocusedItem(focused);
-        }
-
-        ProgressBar p = activity.findViewById(R.id.progressBar);
-        viewModel.observeBusyFlag(getViewLifecycleOwner(), isBusy -> {
-            if (isBusy) {
-//                Handler h = new Handler();
-//                h.postDelayed(() -> {
-//                    if (viewModel.getBusyFlag())
-//                        p.setVisibility(View.VISIBLE);
-//
-//                }, 10);
-                p.setVisibility(View.VISIBLE);
-            }
-            else
-                p.setVisibility(View.INVISIBLE);
-        });
-    }
-    @Override
-    public void onSaveInstanceState(@NonNull Bundle outState) {
-        super.onSaveInstanceState(outState);
-        outState.putBoolean("keep", true);
-        final int focusedItem = viewModel.getFocusedItem();
-        outState.putInt("focused", focusedItem);
-    }
-    private void onFabPressed() {
-        fab.hide();
-        Misc.hideSoftKeyboard(this);
-        if (mListener != null) {
-            SimpleDate date = viewModel.getDate();
-            LedgerTransaction tr =
-                    new LedgerTransaction(null, date, viewModel.getDescription(), mProfile);
-
-            tr.setComment(viewModel.getComment());
-            LedgerTransactionAccount emptyAmountAccount = null;
-            float emptyAmountAccountBalance = 0;
-            for (int i = 0; i < viewModel.getAccountCount(); i++) {
-                LedgerTransactionAccount acc =
-                        new LedgerTransactionAccount(viewModel.getAccount(i));
-                if (acc.getAccountName()
-                       .trim()
-                       .isEmpty())
-                    continue;
-
-                if (acc.isAmountSet()) {
-                    emptyAmountAccountBalance += acc.getAmount();
-                }
-                else {
-                    emptyAmountAccount = acc;
-                }
-
-                tr.addAccount(acc);
-            }
-
-            if (emptyAmountAccount != null)
-                emptyAmountAccount.setAmount(-emptyAmountAccountBalance);
-
-            mListener.onTransactionSave(tr);
-        }
-    }
-
-    @Override
-    public void onAttach(@NotNull Context context) {
-        super.onAttach(context);
-        if (context instanceof OnNewTransactionFragmentInteractionListener) {
-            mListener = (OnNewTransactionFragmentInteractionListener) context;
-        }
-        else {
-            throw new RuntimeException(
-                    context.toString() + " must implement OnFragmentInteractionListener");
-        }
-    }
-
-    @Override
-    public void onDetach() {
-        super.onDetach();
-        mListener = null;
-    }
-
-    /**
-     * This interface must be implemented by activities that contain this
-     * fragment to allow an interaction in this fragment to be communicated
-     * to the activity and potentially other fragments contained in that
-     * activity.
-     * <p>
-     * See the Android Training lesson <a href=
-     * "http://developer.android.com/training/basics/fragments/communicating.html"
-     * >Communicating with Other Fragments</a> for more information.
-     */
-    public interface OnNewTransactionFragmentInteractionListener {
-        void onTransactionSave(LedgerTransaction tr);
-    }
-}
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionItemHolder.java b/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionItemHolder.java
deleted file mode 100644 (file)
index 62f0de4..0000000
+++ /dev/null
@@ -1,732 +0,0 @@
-/*
- * Copyright © 2020 Damyan Ivanov.
- * This file is part of MoLe.
- * MoLe is free software: you can distribute it and/or modify it
- * under the term of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your opinion), any later version.
- *
- * MoLe is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License terms for details.
- *
- * You should have received a copy of the GNU General Public License
- * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package net.ktnx.mobileledger.ui.activity;
-
-import android.annotation.SuppressLint;
-import android.graphics.Typeface;
-import android.text.Editable;
-import android.text.TextUtils;
-import android.text.TextWatcher;
-import android.view.Gravity;
-import android.view.View;
-import android.view.ViewGroup;
-import android.view.inputmethod.EditorInfo;
-import android.widget.AutoCompleteTextView;
-import android.widget.EditText;
-import android.widget.FrameLayout;
-import android.widget.TextView;
-
-import androidx.annotation.ColorInt;
-import androidx.annotation.NonNull;
-import androidx.appcompat.app.AppCompatActivity;
-import androidx.constraintlayout.widget.ConstraintLayout;
-import androidx.lifecycle.Observer;
-import androidx.recyclerview.widget.RecyclerView;
-
-import net.ktnx.mobileledger.R;
-import net.ktnx.mobileledger.async.DescriptionSelectedCallback;
-import net.ktnx.mobileledger.model.Currency;
-import net.ktnx.mobileledger.model.Data;
-import net.ktnx.mobileledger.model.LedgerTransactionAccount;
-import net.ktnx.mobileledger.model.MobileLedgerProfile;
-import net.ktnx.mobileledger.ui.CurrencySelectorFragment;
-import net.ktnx.mobileledger.ui.DatePickerFragment;
-import net.ktnx.mobileledger.ui.TextViewClearHelper;
-import net.ktnx.mobileledger.utils.DimensionUtils;
-import net.ktnx.mobileledger.utils.Logger;
-import net.ktnx.mobileledger.utils.MLDB;
-import net.ktnx.mobileledger.utils.Misc;
-import net.ktnx.mobileledger.utils.SimpleDate;
-
-import java.text.DecimalFormatSymbols;
-import java.text.ParseException;
-import java.util.Date;
-import java.util.Locale;
-
-import static net.ktnx.mobileledger.ui.activity.NewTransactionModel.ItemType;
-
-class NewTransactionItemHolder extends RecyclerView.ViewHolder
-        implements DatePickerFragment.DatePickedListener, DescriptionSelectedCallback {
-    private final String decimalDot;
-    private final TextView tvCurrency;
-    private final Observer<Boolean> showCommentsObserver;
-    private final TextView tvTransactionComment;
-    private final TextView tvDate;
-    private final AutoCompleteTextView tvDescription;
-    private final TextView tvDummy;
-    private final AutoCompleteTextView tvAccount;
-    private final TextView tvComment;
-    private final EditText tvAmount;
-    private final ViewGroup lHead;
-    private final ViewGroup lAccount;
-    private final FrameLayout lPadding;
-    private final MobileLedgerProfile mProfile;
-    private final Observer<SimpleDate> dateObserver;
-    private final Observer<String> descriptionObserver;
-    private final Observer<String> transactionCommentObserver;
-    private final Observer<String> hintObserver;
-    private final Observer<Integer> focusedAccountObserver;
-    private final Observer<Integer> accountCountObserver;
-    private final Observer<Boolean> editableObserver;
-    private final Observer<Currency.Position> currencyPositionObserver;
-    private final Observer<Boolean> currencyGapObserver;
-    private final Observer<Locale> localeObserver;
-    private final Observer<Currency> currencyObserver;
-    private final Observer<Boolean> showCurrencyObserver;
-    private final Observer<String> commentObserver;
-    private final Observer<Boolean> amountValidityObserver;
-    private String decimalSeparator;
-    private NewTransactionModel.Item item;
-    private Date date;
-    private boolean inUpdate = false;
-    private boolean syncingData = false;
-    //TODO multiple amounts with different currencies per posting
-    NewTransactionItemHolder(@NonNull View itemView, NewTransactionItemsAdapter adapter) {
-        super(itemView);
-        lAccount = itemView.findViewById(R.id.ntr_account);
-        tvAccount = lAccount.findViewById(R.id.account_row_acc_name);
-        tvComment = lAccount.findViewById(R.id.comment);
-        tvTransactionComment = itemView.findViewById(R.id.transaction_comment);
-        new TextViewClearHelper().attachToTextView((EditText) tvComment);
-        tvAmount = itemView.findViewById(R.id.account_row_acc_amounts);
-        tvCurrency = itemView.findViewById(R.id.currency);
-        tvDate = itemView.findViewById(R.id.new_transaction_date);
-        tvDescription = itemView.findViewById(R.id.new_transaction_description);
-        tvDummy = itemView.findViewById(R.id.dummy_text);
-        lHead = itemView.findViewById(R.id.ntr_data);
-        lPadding = itemView.findViewById(R.id.ntr_padding);
-        final View commentLayout = itemView.findViewById(R.id.comment_layout);
-        final View transactionCommentLayout =
-                itemView.findViewById(R.id.transaction_comment_layout);
-
-        tvDescription.setNextFocusForwardId(View.NO_ID);
-        tvAccount.setNextFocusForwardId(View.NO_ID);
-        tvAmount.setNextFocusForwardId(View.NO_ID); // magic!
-
-        tvDate.setOnClickListener(v -> pickTransactionDate());
-
-        lAccount.findViewById(R.id.comment_button)
-                .setOnClickListener(v -> {
-                    tvComment.setVisibility(View.VISIBLE);
-                    tvComment.requestFocus();
-                });
-
-        transactionCommentLayout.findViewById(R.id.comment_button)
-                                .setOnClickListener(v -> {
-                                    tvTransactionComment.setVisibility(View.VISIBLE);
-                                    tvTransactionComment.requestFocus();
-                                });
-
-        mProfile = Data.getProfile();
-
-        View.OnFocusChangeListener focusMonitor = (v, hasFocus) -> {
-            final int id = v.getId();
-            if (hasFocus) {
-                boolean wasSyncing = syncingData;
-                syncingData = true;
-                try {
-                    final int pos = getAdapterPosition();
-                    adapter.updateFocusedItem(pos);
-                    switch (id) {
-                        case R.id.account_row_acc_name:
-                            adapter.noteFocusIsOnAccount(pos);
-                            break;
-                        case R.id.account_row_acc_amounts:
-                            adapter.noteFocusIsOnAmount(pos);
-                            break;
-                        case R.id.comment:
-                            adapter.noteFocusIsOnComment(pos);
-                            break;
-                        case R.id.transaction_comment:
-                            adapter.noteFocusIsOnTransactionComment(pos);
-                            break;
-                        case R.id.new_transaction_description:
-                            adapter.noteFocusIsOnDescription(pos);
-                            break;
-                    }
-                }
-                finally {
-                    syncingData = wasSyncing;
-                }
-            }
-
-            if (id == R.id.comment) {
-                commentFocusChanged(tvComment, hasFocus);
-            }
-            else if (id == R.id.transaction_comment) {
-                commentFocusChanged(tvTransactionComment, hasFocus);
-            }
-        };
-
-        tvDescription.setOnFocusChangeListener(focusMonitor);
-        tvAccount.setOnFocusChangeListener(focusMonitor);
-        tvAmount.setOnFocusChangeListener(focusMonitor);
-        tvComment.setOnFocusChangeListener(focusMonitor);
-        tvTransactionComment.setOnFocusChangeListener(focusMonitor);
-
-        MLDB.hookAutocompletionAdapter(tvDescription.getContext(), tvDescription,
-                MLDB.DESCRIPTION_HISTORY_TABLE, "description", false, adapter, mProfile);
-        MLDB.hookAutocompletionAdapter(tvAccount.getContext(), tvAccount, MLDB.ACCOUNTS_TABLE,
-                "name", true, this, mProfile);
-
-        decimalSeparator = String.valueOf(DecimalFormatSymbols.getInstance()
-                                                              .getMonetaryDecimalSeparator());
-        localeObserver = locale -> decimalSeparator = String.valueOf(
-                DecimalFormatSymbols.getInstance(locale)
-                                    .getMonetaryDecimalSeparator());
-
-        decimalDot = ".";
-
-        final TextWatcher tw = new TextWatcher() {
-            @Override
-            public void beforeTextChanged(CharSequence s, int start, int count, int after) {
-            }
-
-            @Override
-            public void onTextChanged(CharSequence s, int start, int before, int count) {
-            }
-
-            @Override
-            public void afterTextChanged(Editable s) {
-//                debug("input", "text changed");
-                if (inUpdate)
-                    return;
-
-                Logger.debug("textWatcher", "calling syncData()");
-                syncData();
-                Logger.debug("textWatcher",
-                        "syncData() returned, checking if transaction is submittable");
-                adapter.checkTransactionSubmittable();
-                Logger.debug("textWatcher", "done");
-            }
-        };
-        final TextWatcher amountWatcher = new TextWatcher() {
-            @Override
-            public void beforeTextChanged(CharSequence s, int start, int count, int after) {
-                Logger.debug("num",
-                        String.format(Locale.US, "beforeTextChanged: start=%d, count=%d, after=%d",
-                                start, count, after));
-            }
-            @Override
-            public void onTextChanged(CharSequence s, int start, int before, int count) {}
-            @Override
-            public void afterTextChanged(Editable s) {
-
-                if (syncData())
-                    adapter.checkTransactionSubmittable();
-            }
-        };
-        tvDescription.addTextChangedListener(tw);
-        tvTransactionComment.addTextChangedListener(tw);
-        tvAccount.addTextChangedListener(tw);
-        tvComment.addTextChangedListener(tw);
-        tvAmount.addTextChangedListener(amountWatcher);
-
-        tvCurrency.setOnClickListener(v -> {
-            CurrencySelectorFragment cpf = new CurrencySelectorFragment();
-            cpf.showPositionAndPadding();
-            cpf.setOnCurrencySelectedListener(c -> item.setCurrency(c));
-            final AppCompatActivity activity = (AppCompatActivity) v.getContext();
-            cpf.show(activity.getSupportFragmentManager(), "currency-selector");
-        });
-
-        dateObserver = date -> {
-            if (syncingData)
-                return;
-            syncingData = true;
-            try {
-                tvDate.setText(item.getFormattedDate());
-            }
-            finally {
-                syncingData = false;
-            }
-        };
-        descriptionObserver = description -> {
-            if (syncingData)
-                return;
-            syncingData = true;
-            try {
-                tvDescription.setText(description);
-            }
-            finally {
-                syncingData = false;
-            }
-        };
-        transactionCommentObserver = transactionComment -> {
-            final View focusedView = tvTransactionComment.findFocus();
-            tvTransactionComment.setTypeface(null,
-                    (focusedView == tvTransactionComment) ? Typeface.NORMAL : Typeface.ITALIC);
-            tvTransactionComment.setVisibility(
-                    ((focusedView != tvTransactionComment) && TextUtils.isEmpty(transactionComment))
-                    ? View.INVISIBLE : View.VISIBLE);
-
-        };
-        hintObserver = hint -> {
-            if (syncingData)
-                return;
-            syncingData = true;
-            try {
-                if (hint == null)
-                    tvAmount.setHint(R.string.zero_amount);
-                else
-                    tvAmount.setHint(hint);
-            }
-            finally {
-                syncingData = false;
-            }
-        };
-        editableObserver = this::setEditable;
-        commentFocusChanged(tvTransactionComment, false);
-        commentFocusChanged(tvComment, false);
-        focusedAccountObserver = index -> {
-            if ((index == null) || !index.equals(getAdapterPosition()) || itemView.hasFocus())
-                return;
-
-            switch (item.getType()) {
-                case generalData:
-                    // bad idea - double pop-up, and not really necessary.
-                    // the user can tap the input to get the calendar
-                    //if (!tvDate.hasFocus()) tvDate.requestFocus();
-                    switch (item.getFocusedElement()) {
-                        case TransactionComment:
-                            tvTransactionComment.setVisibility(View.VISIBLE);
-                            tvTransactionComment.requestFocus();
-                            break;
-                        case Description:
-                            boolean focused = tvDescription.requestFocus();
-                            tvDescription.dismissDropDown();
-                            if (focused)
-                                Misc.showSoftKeyboard(
-                                        (NewTransactionActivity) tvDescription.getContext());
-                            break;
-                    }
-                    break;
-                case transactionRow:
-                    switch (item.getFocusedElement()) {
-                        case Amount:
-                            tvAmount.requestFocus();
-                            break;
-                        case Comment:
-                            tvComment.setVisibility(View.VISIBLE);
-                            tvComment.requestFocus();
-                            break;
-                        case Account:
-                            boolean focused = tvAccount.requestFocus();
-                            tvAccount.dismissDropDown();
-                            if (focused)
-                                Misc.showSoftKeyboard(
-                                        (NewTransactionActivity) tvAccount.getContext());
-                            break;
-                    }
-
-                    break;
-            }
-        };
-        accountCountObserver = count -> {
-            final int adapterPosition = getAdapterPosition();
-            final int layoutPosition = getLayoutPosition();
-            Logger.debug("holder",
-                    String.format(Locale.US, "count=%d; pos=%d, layoutPos=%d [%s]", count,
-                            adapterPosition, layoutPosition, item.getType()
-                                                                 .toString()
-                                                                 .concat(item.getType() ==
-                                                                         ItemType.transactionRow
-                                                                         ? String.format(Locale.US,
-                                                                         "'%s'=%s",
-                                                                         item.getAccount()
-                                                                             .getAccountName(),
-                                                                         item.getAccount()
-                                                                             .isAmountSet()
-                                                                         ? String.format(Locale.US,
-                                                                                 "%.2f",
-                                                                                 item.getAccount()
-                                                                                     .getAmount())
-                                                                         : "unset") : "")));
-            if (adapterPosition == count)
-                tvAmount.setImeOptions(EditorInfo.IME_ACTION_DONE);
-            else
-                tvAmount.setImeOptions(EditorInfo.IME_ACTION_NEXT);
-        };
-
-        currencyObserver = currency -> {
-            setCurrency(currency);
-            adapter.checkTransactionSubmittable();
-        };
-
-        currencyGapObserver =
-                hasGap -> updateCurrencyPositionAndPadding(Data.currencySymbolPosition.getValue(),
-                        hasGap);
-
-        currencyPositionObserver =
-                position -> updateCurrencyPositionAndPadding(position, Data.currencyGap.getValue());
-
-        showCurrencyObserver = showCurrency -> {
-            if (showCurrency) {
-                tvCurrency.setVisibility(View.VISIBLE);
-            }
-            else {
-                tvCurrency.setVisibility(View.GONE);
-                item.setCurrency(null);
-            }
-        };
-
-        commentObserver = comment -> {
-            final View focusedView = tvComment.findFocus();
-            tvComment.setTypeface(null,
-                    (focusedView == tvComment) ? Typeface.NORMAL : Typeface.ITALIC);
-            tvComment.setVisibility(
-                    ((focusedView != tvComment) && TextUtils.isEmpty(comment)) ? View.INVISIBLE
-                                                                               : View.VISIBLE);
-        };
-
-        showCommentsObserver = show -> {
-            final View amountLayout = itemView.findViewById(R.id.amount_layout);
-            ConstraintLayout.LayoutParams amountLayoutParams =
-                    (ConstraintLayout.LayoutParams) amountLayout.getLayoutParams();
-            ConstraintLayout.LayoutParams accountParams =
-                    (ConstraintLayout.LayoutParams) tvAccount.getLayoutParams();
-            if (show) {
-                accountParams.endToStart = ConstraintLayout.LayoutParams.UNSET;
-                accountParams.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID;
-
-                amountLayoutParams.topToTop = ConstraintLayout.LayoutParams.UNSET;
-                amountLayoutParams.topToBottom = tvAccount.getId();
-
-                commentLayout.setVisibility(View.VISIBLE);
-            }
-            else {
-                accountParams.endToStart = amountLayout.getId();
-                accountParams.endToEnd = ConstraintLayout.LayoutParams.UNSET;
-
-                amountLayoutParams.topToBottom = ConstraintLayout.LayoutParams.UNSET;
-                amountLayoutParams.topToTop = ConstraintLayout.LayoutParams.PARENT_ID;
-
-                commentLayout.setVisibility(View.GONE);
-            }
-
-            tvAccount.setLayoutParams(accountParams);
-            amountLayout.setLayoutParams(amountLayoutParams);
-
-            transactionCommentLayout.setVisibility(show ? View.VISIBLE : View.GONE);
-        };
-
-        amountValidityObserver = valid -> {
-            tvAmount.setCompoundDrawablesRelativeWithIntrinsicBounds(
-                    valid ? 0 : R.drawable.ic_error_outline_black_24dp, 0, 0, 0);
-            tvAmount.setMinEms(valid ? 4 : 5);
-        };
-    }
-    private void commentFocusChanged(TextView textView, boolean hasFocus) {
-        @ColorInt int textColor;
-        textColor = tvDummy.getTextColors()
-                           .getDefaultColor();
-        if (hasFocus) {
-            textView.setTypeface(null, Typeface.NORMAL);
-            textView.setHint(R.string.transaction_account_comment_hint);
-        }
-        else {
-            int alpha = (textColor >> 24 & 0xff);
-            alpha = 3 * alpha / 4;
-            textColor = (alpha << 24) | (0x00ffffff & textColor);
-            textView.setTypeface(null, Typeface.ITALIC);
-            textView.setHint("");
-            if (TextUtils.isEmpty(textView.getText())) {
-                textView.setVisibility(View.INVISIBLE);
-            }
-        }
-        textView.setTextColor(textColor);
-
-    }
-    private void updateCurrencyPositionAndPadding(Currency.Position position, boolean hasGap) {
-        ConstraintLayout.LayoutParams amountLP =
-                (ConstraintLayout.LayoutParams) tvAmount.getLayoutParams();
-        ConstraintLayout.LayoutParams currencyLP =
-                (ConstraintLayout.LayoutParams) tvCurrency.getLayoutParams();
-
-        if (position == Currency.Position.before) {
-            currencyLP.startToStart = ConstraintLayout.LayoutParams.PARENT_ID;
-            currencyLP.endToEnd = ConstraintLayout.LayoutParams.UNSET;
-
-            amountLP.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID;
-            amountLP.endToStart = ConstraintLayout.LayoutParams.UNSET;
-            amountLP.startToStart = ConstraintLayout.LayoutParams.UNSET;
-            amountLP.startToEnd = tvCurrency.getId();
-
-            tvCurrency.setGravity(Gravity.END);
-        }
-        else {
-            currencyLP.startToStart = ConstraintLayout.LayoutParams.UNSET;
-            currencyLP.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID;
-
-            amountLP.startToStart = ConstraintLayout.LayoutParams.PARENT_ID;
-            amountLP.startToEnd = ConstraintLayout.LayoutParams.UNSET;
-            amountLP.endToEnd = ConstraintLayout.LayoutParams.UNSET;
-            amountLP.endToStart = tvCurrency.getId();
-
-            tvCurrency.setGravity(Gravity.START);
-        }
-
-        amountLP.resolveLayoutDirection(tvAmount.getLayoutDirection());
-        currencyLP.resolveLayoutDirection(tvCurrency.getLayoutDirection());
-
-        tvAmount.setLayoutParams(amountLP);
-        tvCurrency.setLayoutParams(currencyLP);
-
-        // distance between the amount and the currency symbol
-        int gapSize = DimensionUtils.sp2px(tvCurrency.getContext(), 5);
-
-        if (position == Currency.Position.before) {
-            tvCurrency.setPaddingRelative(0, 0, hasGap ? gapSize : 0, 0);
-        }
-        else {
-            tvCurrency.setPaddingRelative(hasGap ? gapSize : 0, 0, 0, 0);
-        }
-    }
-    private void setCurrencyString(String currency) {
-        @ColorInt int textColor = tvDummy.getTextColors()
-                                         .getDefaultColor();
-        if ((currency == null) || currency.isEmpty()) {
-            tvCurrency.setText(R.string.currency_symbol);
-            int alpha = (textColor >> 24) & 0xff;
-            alpha = alpha * 3 / 4;
-            tvCurrency.setTextColor((alpha << 24) | (0x00ffffff & textColor));
-        }
-        else {
-            tvCurrency.setText(currency);
-            tvCurrency.setTextColor(textColor);
-        }
-    }
-    private void setCurrency(Currency currency) {
-        setCurrencyString((currency == null) ? null : currency.getName());
-    }
-    private void setEditable(Boolean editable) {
-        tvDate.setEnabled(editable);
-        tvDescription.setEnabled(editable);
-        tvAccount.setEnabled(editable);
-        tvAmount.setEnabled(editable);
-    }
-    private void beginUpdates() {
-        if (inUpdate)
-            throw new RuntimeException("Already in update mode");
-        inUpdate = true;
-    }
-    private void endUpdates() {
-        if (!inUpdate)
-            throw new RuntimeException("Not in update mode");
-        inUpdate = false;
-    }
-    /**
-     * syncData()
-     * <p>
-     * Stores the data from the UI elements into the model item
-     * Returns true if there were changes made that suggest transaction has to be
-     * checked for being submittable
-     */
-    private boolean syncData() {
-        if (item == null)
-            return false;
-
-        if (syncingData) {
-            Logger.debug("new-trans", "skipping syncData() loop");
-            return false;
-        }
-
-        syncingData = true;
-
-        try {
-            switch (item.getType()) {
-                case generalData:
-                    item.setDate(String.valueOf(tvDate.getText()));
-                    item.setDescription(String.valueOf(tvDescription.getText()));
-                    item.setTransactionComment(String.valueOf(tvTransactionComment.getText()));
-                    break;
-                case transactionRow:
-                    final LedgerTransactionAccount account = item.getAccount();
-                    account.setAccountName(String.valueOf(tvAccount.getText()));
-
-                    item.setComment(String.valueOf(tvComment.getText()));
-
-                    String amount = String.valueOf(tvAmount.getText());
-                    amount = amount.trim();
-
-                    if (amount.isEmpty()) {
-                        account.resetAmount();
-                        item.validateAmount();
-                    }
-                    else {
-                        try {
-                            amount = amount.replace(decimalSeparator, decimalDot);
-                            account.setAmount(Float.parseFloat(amount));
-                            item.validateAmount();
-                        }
-                        catch (NumberFormatException e) {
-                            Logger.debug("new-trans", String.format(
-                                    "assuming amount is not set due to number format exception. " +
-                                    "input was '%s'", amount));
-                            account.invalidateAmount();
-                            item.invalidateAmount();
-                        }
-                        final String curr = String.valueOf(tvCurrency.getText());
-                        if (curr.equals(tvCurrency.getContext()
-                                                  .getResources()
-                                                  .getString(R.string.currency_symbol)) ||
-                            curr.isEmpty())
-                            account.setCurrency(null);
-                        else
-                            account.setCurrency(curr);
-                    }
-
-                    break;
-                case bottomFiller:
-                    throw new RuntimeException("Should not happen");
-            }
-
-            return true;
-        }
-        catch (ParseException e) {
-            throw new RuntimeException("Should not happen", e);
-        }
-        finally {
-            syncingData = false;
-        }
-    }
-    private void pickTransactionDate() {
-        DatePickerFragment picker = new DatePickerFragment();
-        picker.setFutureDates(mProfile.getFutureDates());
-        picker.setOnDatePickedListener(this);
-        picker.setCurrentDateFromText(tvDate.getText());
-        picker.show(((NewTransactionActivity) tvDate.getContext()).getSupportFragmentManager(),
-                null);
-    }
-    /**
-     * setData
-     *
-     * @param item updates the UI elements with the data from the model item
-     */
-    @SuppressLint("DefaultLocale")
-    public void setData(NewTransactionModel.Item item) {
-        beginUpdates();
-        try {
-            if (this.item != null && !this.item.equals(item)) {
-                this.item.stopObservingDate(dateObserver);
-                this.item.stopObservingDescription(descriptionObserver);
-                this.item.stopObservingTransactionComment(transactionCommentObserver);
-                this.item.stopObservingAmountHint(hintObserver);
-                this.item.stopObservingEditableFlag(editableObserver);
-                this.item.getModel()
-                         .stopObservingFocusedItem(focusedAccountObserver);
-                this.item.getModel()
-                         .stopObservingAccountCount(accountCountObserver);
-                Data.currencySymbolPosition.removeObserver(currencyPositionObserver);
-                Data.currencyGap.removeObserver(currencyGapObserver);
-                Data.locale.removeObserver(localeObserver);
-                this.item.stopObservingCurrency(currencyObserver);
-                this.item.getModel().showCurrency.removeObserver(showCurrencyObserver);
-                this.item.stopObservingComment(commentObserver);
-                this.item.getModel().showComments.removeObserver(showCommentsObserver);
-                this.item.stopObservingAmountValidity(amountValidityObserver);
-
-                this.item = null;
-            }
-
-            switch (item.getType()) {
-                case generalData:
-                    tvDate.setText(item.getFormattedDate());
-                    tvDescription.setText(item.getDescription());
-                    tvTransactionComment.setText(item.getTransactionComment());
-                    lHead.setVisibility(View.VISIBLE);
-                    lAccount.setVisibility(View.GONE);
-                    lPadding.setVisibility(View.GONE);
-                    setEditable(true);
-                    break;
-                case transactionRow:
-                    LedgerTransactionAccount acc = item.getAccount();
-                    tvAccount.setText(acc.getAccountName());
-                    tvComment.setText(acc.getComment());
-                    if (acc.isAmountSet()) {
-                        tvAmount.setText(String.format("%1.2f", acc.getAmount()));
-                    }
-                    else {
-                        tvAmount.setText("");
-//                        tvAmount.setHint(R.string.zero_amount);
-                    }
-                    tvAmount.setHint(item.getAmountHint());
-                    setCurrencyString(acc.getCurrency());
-                    lHead.setVisibility(View.GONE);
-                    lAccount.setVisibility(View.VISIBLE);
-                    lPadding.setVisibility(View.GONE);
-                    setEditable(true);
-                    break;
-                case bottomFiller:
-                    lHead.setVisibility(View.GONE);
-                    lAccount.setVisibility(View.GONE);
-                    lPadding.setVisibility(View.VISIBLE);
-                    setEditable(false);
-                    break;
-            }
-            if (this.item == null) { // was null or has changed
-                this.item = item;
-                final NewTransactionActivity activity =
-                        (NewTransactionActivity) tvDescription.getContext();
-
-                if (!item.isBottomFiller()) {
-                    item.observeEditableFlag(activity, editableObserver);
-                    item.getModel()
-                        .observeFocusedItem(activity, focusedAccountObserver);
-                    item.getModel()
-                        .observeShowComments(activity, showCommentsObserver);
-                }
-                switch (item.getType()) {
-                    case generalData:
-                        item.observeDate(activity, dateObserver);
-                        item.observeDescription(activity, descriptionObserver);
-                        item.observeTransactionComment(activity, transactionCommentObserver);
-                        break;
-                    case transactionRow:
-                        item.observeAmountHint(activity, hintObserver);
-                        Data.currencySymbolPosition.observe(activity, currencyPositionObserver);
-                        Data.currencyGap.observe(activity, currencyGapObserver);
-                        Data.locale.observe(activity, localeObserver);
-                        item.observeCurrency(activity, currencyObserver);
-                        item.getModel().showCurrency.observe(activity, showCurrencyObserver);
-                        item.observeComment(activity, commentObserver);
-                        item.getModel()
-                            .observeAccountCount(activity, accountCountObserver);
-                        item.observeAmountValidity(activity, amountValidityObserver);
-                        break;
-                }
-            }
-        }
-        finally {
-            endUpdates();
-        }
-    }
-    @Override
-    public void onDatePicked(int year, int month, int day) {
-        item.setDate(new SimpleDate(year, month + 1, day));
-        boolean focused = tvDescription.requestFocus();
-        if (focused)
-            Misc.showSoftKeyboard((NewTransactionActivity) tvAccount.getContext());
-
-    }
-    @Override
-    public void descriptionSelected(String description) {
-        tvAccount.setText(description);
-        tvAmount.requestFocus(View.FOCUS_FORWARD);
-    }
-}
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionItemsAdapter.java b/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionItemsAdapter.java
deleted file mode 100644 (file)
index 0da28e8..0000000
+++ /dev/null
@@ -1,697 +0,0 @@
-/*
- * Copyright © 2020 Damyan Ivanov.
- * This file is part of MoLe.
- * MoLe is free software: you can distribute it and/or modify it
- * under the term of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your opinion), any later version.
- *
- * MoLe is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License terms for details.
- *
- * You should have received a copy of the GNU General Public License
- * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package net.ktnx.mobileledger.ui.activity;
-
-import android.annotation.SuppressLint;
-import android.app.Activity;
-import android.database.Cursor;
-import android.text.TextUtils;
-import android.view.LayoutInflater;
-import android.view.ViewGroup;
-import android.widget.LinearLayout;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.recyclerview.widget.ItemTouchHelper;
-import androidx.recyclerview.widget.RecyclerView;
-
-import com.google.android.material.snackbar.Snackbar;
-
-import net.ktnx.mobileledger.BuildConfig;
-import net.ktnx.mobileledger.R;
-import net.ktnx.mobileledger.async.DescriptionSelectedCallback;
-import net.ktnx.mobileledger.model.Currency;
-import net.ktnx.mobileledger.model.Data;
-import net.ktnx.mobileledger.model.LedgerTransaction;
-import net.ktnx.mobileledger.model.LedgerTransactionAccount;
-import net.ktnx.mobileledger.model.MobileLedgerProfile;
-import net.ktnx.mobileledger.utils.Logger;
-import net.ktnx.mobileledger.utils.MLDB;
-import net.ktnx.mobileledger.utils.Misc;
-
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Locale;
-import java.util.Set;
-
-import static net.ktnx.mobileledger.utils.Logger.debug;
-
-class NewTransactionItemsAdapter extends RecyclerView.Adapter<NewTransactionItemHolder>
-        implements DescriptionSelectedCallback {
-    private final NewTransactionModel model;
-    private MobileLedgerProfile mProfile;
-    private final ItemTouchHelper touchHelper;
-    private RecyclerView recyclerView;
-    private int checkHoldCounter = 0;
-    NewTransactionItemsAdapter(NewTransactionModel viewModel, MobileLedgerProfile profile) {
-        super();
-        model = viewModel;
-        mProfile = profile;
-        int size = model.getAccountCount();
-        while (size < 2) {
-            Logger.debug("new-transaction",
-                    String.format(Locale.US, "%d accounts is too little, Calling addRow()", size));
-            size = addRow();
-        }
-
-        NewTransactionItemsAdapter adapter = this;
-
-        touchHelper = new ItemTouchHelper(new ItemTouchHelper.Callback() {
-            @Override
-            public boolean isLongPressDragEnabled() {
-                return true;
-            }
-            @Override
-            public boolean canDropOver(@NonNull RecyclerView recyclerView,
-                                       @NonNull RecyclerView.ViewHolder current,
-                                       @NonNull RecyclerView.ViewHolder target) {
-                final int adapterPosition = target.getAdapterPosition();
-
-                // first and last items are immovable
-                if (adapterPosition == 0)
-                    return false;
-                if (adapterPosition == adapter.getItemCount() - 1)
-                    return false;
-
-                return super.canDropOver(recyclerView, current, target);
-            }
-            @Override
-            public int getMovementFlags(@NonNull RecyclerView recyclerView,
-                                        @NonNull RecyclerView.ViewHolder viewHolder) {
-                int flags = makeFlag(ItemTouchHelper.ACTION_STATE_IDLE, ItemTouchHelper.END);
-                // the top (date and description) and the bottom (padding) items are always there
-                final int adapterPosition = viewHolder.getAdapterPosition();
-                if ((adapterPosition > 0) && (adapterPosition < adapter.getItemCount() - 1)) {
-                    flags |= makeFlag(ItemTouchHelper.ACTION_STATE_DRAG,
-                            ItemTouchHelper.UP | ItemTouchHelper.DOWN) |
-                             makeFlag(ItemTouchHelper.ACTION_STATE_SWIPE,
-                                     ItemTouchHelper.START | ItemTouchHelper.END);
-                }
-
-                return flags;
-            }
-            @Override
-            public boolean onMove(@NonNull RecyclerView recyclerView,
-                                  @NonNull RecyclerView.ViewHolder viewHolder,
-                                  @NonNull RecyclerView.ViewHolder target) {
-
-                model.swapItems(viewHolder.getAdapterPosition(), target.getAdapterPosition());
-                notifyItemMoved(viewHolder.getAdapterPosition(), target.getAdapterPosition());
-                return true;
-            }
-            @Override
-            public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) {
-                int pos = viewHolder.getAdapterPosition();
-                viewModel.removeItem(pos - 1);
-                notifyItemRemoved(pos);
-                viewModel.sendCountNotifications(); // needed after items re-arrangement
-                checkTransactionSubmittable();
-            }
-        });
-    }
-    public void setProfile(MobileLedgerProfile profile) {
-        mProfile = profile;
-    }
-    private int addRow() {
-        return addRow(null);
-    }
-    private int addRow(String commodity) {
-        final int newAccountCount = model.addAccount(new LedgerTransactionAccount("", commodity));
-        Logger.debug("new-transaction",
-                String.format(Locale.US, "invoking notifyItemInserted(%d)", newAccountCount));
-        // the header is at position 0
-        notifyItemInserted(newAccountCount);
-        model.sendCountNotifications(); // needed after holders' positions have changed
-        return newAccountCount;
-    }
-    @NonNull
-    @Override
-    public NewTransactionItemHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
-        LinearLayout row = (LinearLayout) LayoutInflater.from(parent.getContext())
-                                                        .inflate(R.layout.new_transaction_row,
-                                                                parent, false);
-
-        return new NewTransactionItemHolder(row, this);
-    }
-    @Override
-    public void onBindViewHolder(@NonNull NewTransactionItemHolder holder, int position) {
-        Logger.debug("bind", String.format(Locale.US, "Binding item at position %d", position));
-        NewTransactionModel.Item item = model.getItem(position);
-        holder.setData(item);
-        Logger.debug("bind", String.format(Locale.US, "Bound %s item at position %d", item.getType()
-                                                                                          .toString(),
-                position));
-    }
-    @Override
-    public int getItemCount() {
-        return model.getAccountCount() + 2;
-    }
-    private boolean accountListIsEmpty() {
-        for (int i = 0; i < model.getAccountCount(); i++) {
-            LedgerTransactionAccount acc = model.getAccount(i);
-            if (!acc.getAccountName()
-                    .isEmpty())
-                return false;
-            if (acc.isAmountSet())
-                return false;
-        }
-
-        return true;
-    }
-    @Override
-    public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
-        super.onAttachedToRecyclerView(recyclerView);
-        this.recyclerView = recyclerView;
-        touchHelper.attachToRecyclerView(recyclerView);
-    }
-    @Override
-    public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) {
-        touchHelper.attachToRecyclerView(null);
-        super.onDetachedFromRecyclerView(recyclerView);
-        this.recyclerView = null;
-    }
-    public void descriptionSelected(String description) {
-        debug("description selected", description);
-        if (!accountListIsEmpty())
-            return;
-
-        String accFilter = mProfile.getPreferredAccountsFilter();
-
-        ArrayList<String> params = new ArrayList<>();
-        StringBuilder sb = new StringBuilder("select t.profile, t.id from transactions t");
-
-        if (!TextUtils.isEmpty(accFilter)) {
-            sb.append(" JOIN transaction_accounts ta")
-              .append(" ON ta.profile = t.profile")
-              .append(" AND ta.transaction_id = t.id");
-        }
-
-        sb.append(" WHERE t.description=?");
-        params.add(description);
-
-        if (!TextUtils.isEmpty(accFilter)) {
-            sb.append(" AND ta.account_name LIKE '%'||?||'%'");
-            params.add(accFilter);
-        }
-
-        sb.append(" ORDER BY t.year desc, t.month desc, t.day desc LIMIT 1");
-
-        final String sql = sb.toString();
-        debug("description", sql);
-        debug("description", params.toString());
-
-        Activity activity = (Activity) recyclerView.getContext();
-        // FIXME: handle exceptions?
-        MLDB.queryInBackground(sql, params.toArray(new String[]{}), new MLDB.CallbackHelper() {
-            @Override
-            public void onStart() {
-                model.incrementBusyCounter();
-            }
-            @Override
-            public void onDone() {
-                model.decrementBusyCounter();
-            }
-            @Override
-            public boolean onRow(@NonNull Cursor cursor) {
-                final String profileUUID = cursor.getString(0);
-                final int transactionId = cursor.getInt(1);
-                activity.runOnUiThread(() -> loadTransactionIntoModel(profileUUID, transactionId));
-                return false; // limit 1, by the way
-            }
-            @Override
-            public void onNoRows() {
-                if (TextUtils.isEmpty(accFilter))
-                    return;
-
-                debug("description", "Trying transaction search without preferred account filter");
-
-                final String broaderSql =
-                        "select t.profile, t.id from transactions t where t.description=?" +
-                        " ORDER BY year desc, month desc, day desc LIMIT 1";
-                params.remove(1);
-                debug("description", broaderSql);
-                debug("description", description);
-
-                activity.runOnUiThread(
-                        () -> Snackbar.make(recyclerView, R.string.ignoring_preferred_account,
-                                Snackbar.LENGTH_LONG)
-                                      .show());
-
-                MLDB.queryInBackground(broaderSql, new String[]{description},
-                        new MLDB.CallbackHelper() {
-                            @Override
-                            public void onStart() {
-                                model.incrementBusyCounter();
-                            }
-                            @Override
-                            public boolean onRow(@NonNull Cursor cursor) {
-                                final String profileUUID = cursor.getString(0);
-                                final int transactionId = cursor.getInt(1);
-                                activity.runOnUiThread(
-                                        () -> loadTransactionIntoModel(profileUUID, transactionId));
-                                return false;
-                            }
-                            @Override
-                            public void onDone() {
-                                model.decrementBusyCounter();
-                            }
-                        });
-            }
-        });
-    }
-    private void loadTransactionIntoModel(String profileUUID, int transactionId) {
-        LedgerTransaction tr;
-        MobileLedgerProfile profile = Data.getProfile(profileUUID);
-        if (profile == null)
-            throw new RuntimeException(String.format(
-                    "Unable to find profile %s, which is supposed to contain transaction %d",
-                    profileUUID, transactionId));
-
-        tr = profile.loadTransaction(transactionId);
-        List<LedgerTransactionAccount> accounts = tr.getAccounts();
-        NewTransactionModel.Item firstNegative = null;
-        NewTransactionModel.Item firstPositive = null;
-        int singleNegativeIndex = -1;
-        int singlePositiveIndex = -1;
-        int negativeCount = 0;
-        for (int i = 0; i < accounts.size(); i++) {
-            LedgerTransactionAccount acc = accounts.get(i);
-            NewTransactionModel.Item item;
-            if (model.getAccountCount() < i + 1) {
-                model.addAccount(acc);
-                notifyItemInserted(i + 1);
-            }
-            item = model.getItem(i + 1);
-
-            item.getAccount()
-                .setAccountName(acc.getAccountName());
-            item.setComment(acc.getComment());
-            if (acc.isAmountSet()) {
-                item.getAccount()
-                    .setAmount(acc.getAmount());
-                if (acc.getAmount() < 0) {
-                    if (firstNegative == null) {
-                        firstNegative = item;
-                        singleNegativeIndex = i;
-                    }
-                    else
-                        singleNegativeIndex = -1;
-                }
-                else {
-                    if (firstPositive == null) {
-                        firstPositive = item;
-                        singlePositiveIndex = i;
-                    }
-                    else
-                        singlePositiveIndex = -1;
-                }
-            }
-            else
-                item.getAccount()
-                    .resetAmount();
-            notifyItemChanged(i + 1);
-        }
-
-        if (singleNegativeIndex != -1) {
-            firstNegative.getAccount()
-                         .resetAmount();
-            model.moveItemLast(singleNegativeIndex);
-        }
-        else if (singlePositiveIndex != -1) {
-            firstPositive.getAccount()
-                         .resetAmount();
-            model.moveItemLast(singlePositiveIndex);
-        }
-
-        checkTransactionSubmittable();
-        model.setFocusedItem(1);
-    }
-    public void toggleAllEditing(boolean editable) {
-        // item 0 is the header
-        for (int i = 0; i <= model.getAccountCount(); i++) {
-            model.getItem(i)
-                 .setEditable(editable);
-            notifyItemChanged(i);
-            // TODO perhaps do only one notification about the whole range (notifyDatasetChanged)?
-        }
-    }
-    void reset() {
-        int presentItemCount = model.getAccountCount();
-        model.reset();
-        notifyItemChanged(0);       // header changed
-        notifyItemRangeChanged(1, 2);    // the two empty rows
-        if (presentItemCount > 2)
-            notifyItemRangeRemoved(3, presentItemCount - 2); // all the rest are gone
-    }
-    void updateFocusedItem(int position) {
-        model.updateFocusedItem(position);
-    }
-    void noteFocusIsOnAccount(int position) {
-        model.noteFocusChanged(position, NewTransactionModel.FocusedElement.Account);
-    }
-    void noteFocusIsOnAmount(int position) {
-        model.noteFocusChanged(position, NewTransactionModel.FocusedElement.Amount);
-    }
-    void noteFocusIsOnComment(int position) {
-        model.noteFocusChanged(position, NewTransactionModel.FocusedElement.Comment);
-    }
-    void noteFocusIsOnTransactionComment(int position) {
-        model.noteFocusChanged(position, NewTransactionModel.FocusedElement.TransactionComment);
-    }
-    public void noteFocusIsOnDescription(int pos) {
-        model.noteFocusChanged(pos, NewTransactionModel.FocusedElement.Description);
-    }
-    private void holdSubmittableChecks() {
-        checkHoldCounter++;
-    }
-    private void releaseSubmittableChecks() {
-        if (checkHoldCounter == 0)
-            throw new RuntimeException("Asymmetrical call to releaseSubmittableChecks");
-        checkHoldCounter--;
-    }
-    void setItemCurrency(NewTransactionModel.Item item, Currency newCurrency) {
-        Currency oldCurrency = item.getCurrency();
-        if (!Currency.equal(newCurrency, oldCurrency)) {
-            holdSubmittableChecks();
-            try {
-                item.setCurrency(newCurrency);
-//                for (Item i : items) {
-//                    if (Currency.equal(i.getCurrency(), oldCurrency))
-//                        i.setCurrency(newCurrency);
-//                }
-            }
-            finally {
-                releaseSubmittableChecks();
-            }
-
-            checkTransactionSubmittable();
-        }
-    }
-    /*
-         A transaction is submittable if:
-         0) has description
-         1) has at least two account names
-         2) each row with amount has account name
-         3) for each commodity:
-         3a) amounts must balance to 0, or
-         3b) there must be exactly one empty amount (with account)
-         4) empty accounts with empty amounts are ignored
-         Side effects:
-         5) a row with an empty account name or empty amount is guaranteed to exist for each
-         commodity
-         6) at least two rows need to be present in the ledger
-
-        */
-    @SuppressLint("DefaultLocale")
-    void checkTransactionSubmittable() {
-        if (checkHoldCounter > 0)
-            return;
-
-        int accounts = 0;
-        final BalanceForCurrency balance = new BalanceForCurrency();
-        final String descriptionText = model.getDescription();
-        boolean submittable = true;
-        final ItemsForCurrency itemsForCurrency = new ItemsForCurrency();
-        final ItemsForCurrency itemsWithEmptyAmountForCurrency = new ItemsForCurrency();
-        final ItemsForCurrency itemsWithAccountAndEmptyAmountForCurrency = new ItemsForCurrency();
-        final ItemsForCurrency itemsWithEmptyAccountForCurrency = new ItemsForCurrency();
-        final ItemsForCurrency itemsWithAmountForCurrency = new ItemsForCurrency();
-        final ItemsForCurrency itemsWithAccountForCurrency = new ItemsForCurrency();
-        final ItemsForCurrency emptyRowsForCurrency = new ItemsForCurrency();
-        final List<NewTransactionModel.Item> emptyRows = new ArrayList<>();
-
-        try {
-            if ((descriptionText == null) || descriptionText.trim()
-                                                            .isEmpty())
-            {
-                Logger.debug("submittable", "Transaction not submittable: missing description");
-                submittable = false;
-            }
-
-            for (int i = 0; i < model.items.size(); i++) {
-                NewTransactionModel.Item item = model.items.get(i);
-
-                LedgerTransactionAccount acc = item.getAccount();
-                String acc_name = acc.getAccountName()
-                                     .trim();
-                String currName = acc.getCurrency();
-
-                itemsForCurrency.add(currName, item);
-
-                if (acc_name.isEmpty()) {
-                    itemsWithEmptyAccountForCurrency.add(currName, item);
-
-                    if (acc.isAmountSet()) {
-                        // 2) each amount has account name
-                        Logger.debug("submittable", String.format(
-                                "Transaction not submittable: row %d has no account name, but" +
-                                " has" + " amount %1.2f", i + 1, acc.getAmount()));
-                        submittable = false;
-                    }
-                    else {
-                        emptyRowsForCurrency.add(currName, item);
-                    }
-                }
-                else {
-                    accounts++;
-                    itemsWithAccountForCurrency.add(currName, item);
-                }
-
-                if (!acc.isAmountValid()) {
-                    Logger.debug("submittable",
-                            String.format("Not submittable: row %d has an invalid amount", i + 1));
-                    submittable = false;
-                }
-                else if (acc.isAmountSet()) {
-                    itemsWithAmountForCurrency.add(currName, item);
-                    balance.add(currName, acc.getAmount());
-                }
-                else {
-                    itemsWithEmptyAmountForCurrency.add(currName, item);
-
-                    if (!acc_name.isEmpty())
-                        itemsWithAccountAndEmptyAmountForCurrency.add(currName, item);
-                }
-            }
-
-            // 1) has at least two account names
-            if (accounts < 2) {
-                if (accounts == 0)
-                    Logger.debug("submittable",
-                            "Transaction not submittable: no account " + "names");
-                else if (accounts == 1)
-                    Logger.debug("submittable",
-                            "Transaction not submittable: only one account name");
-                else
-                    Logger.debug("submittable",
-                            String.format("Transaction not submittable: only %d account names",
-                                    accounts));
-                submittable = false;
-            }
-
-            // 3) for each commodity:
-            // 3a) amount must balance to 0, or
-            // 3b) there must be exactly one empty amount (with account)
-            for (String balCurrency : itemsForCurrency.currencies()) {
-                float currencyBalance = balance.get(balCurrency);
-                if (Misc.isZero(currencyBalance)) {
-                    // remove hints from all amount inputs in that currency
-                    for (NewTransactionModel.Item item : model.items) {
-                        if (Currency.equal(item.getCurrency(), balCurrency))
-                            item.setAmountHint(null);
-                    }
-                }
-                else {
-                    List<NewTransactionModel.Item> list =
-                            itemsWithAccountAndEmptyAmountForCurrency.getList(balCurrency);
-                    int balanceReceiversCount = list.size();
-                    if (balanceReceiversCount != 1) {
-                        if (BuildConfig.DEBUG) {
-                            if (balanceReceiversCount == 0)
-                                Logger.debug("submittable", String.format(
-                                        "Transaction not submittable [%s]: non-zero balance " +
-                                        "with no empty amounts with accounts", balCurrency));
-                            else
-                                Logger.debug("submittable", String.format(
-                                        "Transaction not submittable [%s]: non-zero balance " +
-                                        "with multiple empty amounts with accounts", balCurrency));
-                        }
-                        submittable = false;
-                    }
-
-                    List<NewTransactionModel.Item> emptyAmountList =
-                            itemsWithEmptyAmountForCurrency.getList(balCurrency);
-
-                    // suggest off-balance amount to a row and remove hints on other rows
-                    NewTransactionModel.Item receiver = null;
-                    if (!list.isEmpty())
-                        receiver = list.get(0);
-                    else if (!emptyAmountList.isEmpty())
-                        receiver = emptyAmountList.get(0);
-
-                    for (NewTransactionModel.Item item : model.items) {
-                        if (!Currency.equal(item.getCurrency(), balCurrency))
-                            continue;
-
-                        if (item.equals(receiver)) {
-                            if (BuildConfig.DEBUG)
-                                Logger.debug("submittable",
-                                        String.format("Setting amount hint to %1.2f [%s]",
-                                                -currencyBalance, balCurrency));
-                            item.setAmountHint(String.format("%1.2f", -currencyBalance));
-                        }
-                        else {
-                            if (BuildConfig.DEBUG)
-                                Logger.debug("submittable",
-                                        String.format("Resetting hint of '%s' [%s]",
-                                                (item.getAccount() == null) ? "" : item.getAccount()
-                                                                                       .getAccountName(),
-                                                balCurrency));
-                            item.setAmountHint(null);
-                        }
-                    }
-                }
-            }
-
-            // 5) a row with an empty account name or empty amount is guaranteed to exist for
-            // each commodity
-            for (String balCurrency : balance.currencies()) {
-                int currEmptyRows = itemsWithEmptyAccountForCurrency.size(balCurrency);
-                int currRows = itemsForCurrency.size(balCurrency);
-                int currAccounts = itemsWithAccountForCurrency.size(balCurrency);
-                int currAmounts = itemsWithAmountForCurrency.size(balCurrency);
-                if ((currEmptyRows == 0) &&
-                    ((currRows == currAccounts) || (currRows == currAmounts)))
-                {
-                    // perhaps there already is an unused empty row for another currency that
-                    // is not used?
-//                        boolean foundIt = false;
-//                        for (Item item : emptyRows) {
-//                            Currency itemCurrency = item.getCurrency();
-//                            String itemCurrencyName =
-//                                    (itemCurrency == null) ? "" : itemCurrency.getName();
-//                            if (Misc.isZero(balance.get(itemCurrencyName))) {
-//                                item.setCurrency(Currency.loadByName(balCurrency));
-//                                item.setAmountHint(
-//                                        String.format("%1.2f", -balance.get(balCurrency)));
-//                                foundIt = true;
-//                                break;
-//                            }
-//                        }
-//
-//                        if (!foundIt)
-                    addRow(balCurrency);
-                }
-            }
-
-            // drop extra empty rows, not needed
-            for (String currName : emptyRowsForCurrency.currencies()) {
-                List<NewTransactionModel.Item> emptyItems = emptyRowsForCurrency.getList(currName);
-                while ((model.items.size() > 2) && (emptyItems.size() > 1)) {
-                    NewTransactionModel.Item item = emptyItems.get(1);
-                    emptyItems.remove(1);
-                    model.removeRow(item, this);
-                }
-
-                // unused currency, remove last item (which is also an empty one)
-                if ((model.items.size() > 2) && (emptyItems.size() == 1)) {
-                    List<NewTransactionModel.Item> currItems = itemsForCurrency.getList(currName);
-
-                    if (currItems.size() == 1) {
-                        NewTransactionModel.Item item = emptyItems.get(0);
-                        model.removeRow(item, this);
-                    }
-                }
-            }
-
-            // 6) at least two rows need to be present in the ledger
-            while (model.items.size() < 2)
-                addRow();
-
-
-            debug("submittable", submittable ? "YES" : "NO");
-            model.isSubmittable.setValue(submittable);
-
-            if (BuildConfig.DEBUG) {
-                debug("submittable", "== Dump of all items");
-                for (int i = 0; i < model.items.size(); i++) {
-                    NewTransactionModel.Item item = model.items.get(i);
-                    LedgerTransactionAccount acc = item.getAccount();
-                    debug("submittable", String.format("Item %2d: [%4.2f(%s) %s] %s ; %s", i,
-                            acc.isAmountSet() ? acc.getAmount() : 0,
-                            item.isAmountHintSet() ? item.getAmountHint() : "ø", acc.getCurrency(),
-                            acc.getAccountName(), acc.getComment()));
-                }
-            }
-        }
-        catch (NumberFormatException e) {
-            debug("submittable", "NO (because of NumberFormatException)");
-            model.isSubmittable.setValue(false);
-        }
-        catch (Exception e) {
-            e.printStackTrace();
-            debug("submittable", "NO (because of an Exception)");
-            model.isSubmittable.setValue(false);
-        }
-    }
-
-    private static class BalanceForCurrency {
-        private final HashMap<String, Float> hashMap = new HashMap<>();
-        float get(String currencyName) {
-            Float f = hashMap.get(currencyName);
-            if (f == null) {
-                f = 0f;
-                hashMap.put(currencyName, f);
-            }
-            return f;
-        }
-        void add(String currencyName, float amount) {
-            hashMap.put(currencyName, get(currencyName) + amount);
-        }
-        Set<String> currencies() {
-            return hashMap.keySet();
-        }
-        boolean containsCurrency(String currencyName) {
-            return hashMap.containsKey(currencyName);
-        }
-    }
-
-    private static class ItemsForCurrency {
-        private final HashMap<String, List<NewTransactionModel.Item>> hashMap = new HashMap<>();
-        @NonNull
-        List<NewTransactionModel.Item> getList(@Nullable String currencyName) {
-            List<NewTransactionModel.Item> list = hashMap.get(currencyName);
-            if (list == null) {
-                list = new ArrayList<>();
-                hashMap.put(currencyName, list);
-            }
-            return list;
-        }
-        void add(@Nullable String currencyName, @NonNull NewTransactionModel.Item item) {
-            getList(currencyName).add(item);
-        }
-        int size(@Nullable String currencyName) {
-            return this.getList(currencyName)
-                       .size();
-        }
-        Set<String> currencies() {
-            return hashMap.keySet();
-        }
-    }
-}
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionModel.java b/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionModel.java
deleted file mode 100644 (file)
index 8660c21..0000000
+++ /dev/null
@@ -1,467 +0,0 @@
-/*
- * Copyright © 2020 Damyan Ivanov.
- * This file is part of MoLe.
- * MoLe is free software: you can distribute it and/or modify it
- * under the term of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your opinion), any later version.
- *
- * MoLe is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License terms for details.
- *
- * You should have received a copy of the GNU General Public License
- * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package net.ktnx.mobileledger.ui.activity;
-
-import androidx.annotation.NonNull;
-import androidx.lifecycle.LifecycleOwner;
-import androidx.lifecycle.LiveData;
-import androidx.lifecycle.MutableLiveData;
-import androidx.lifecycle.Observer;
-import androidx.lifecycle.ViewModel;
-
-import net.ktnx.mobileledger.model.Currency;
-import net.ktnx.mobileledger.model.Data;
-import net.ktnx.mobileledger.model.LedgerTransactionAccount;
-import net.ktnx.mobileledger.model.MobileLedgerProfile;
-import net.ktnx.mobileledger.utils.Globals;
-import net.ktnx.mobileledger.utils.SimpleDate;
-
-import org.jetbrains.annotations.NotNull;
-
-import java.text.ParseException;
-import java.util.ArrayList;
-import java.util.Calendar;
-import java.util.Collections;
-import java.util.GregorianCalendar;
-import java.util.Locale;
-import java.util.concurrent.atomic.AtomicInteger;
-
-public class NewTransactionModel extends ViewModel {
-    final MutableLiveData<Boolean> showCurrency = new MutableLiveData<>(false);
-    final ArrayList<Item> items = new ArrayList<>();
-    final MutableLiveData<Boolean> isSubmittable = new MutableLiveData<>(false);
-    final MutableLiveData<Boolean> showComments = new MutableLiveData<>(true);
-    private final Item header = new Item(this, "");
-    private final Item trailer = new Item(this);
-    private final MutableLiveData<Integer> focusedItem = new MutableLiveData<>(0);
-    private final MutableLiveData<Integer> accountCount = new MutableLiveData<>(0);
-    private final MutableLiveData<Boolean> simulateSave = new MutableLiveData<>(false);
-    private final AtomicInteger busyCounter = new AtomicInteger(0);
-    private final MutableLiveData<Boolean> busyFlag = new MutableLiveData<>(false);
-    private final Observer<MobileLedgerProfile> profileObserver = profile -> {
-        showCurrency.postValue(profile.getShowCommodityByDefault());
-        showComments.postValue(profile.getShowCommentsByDefault());
-    };
-    private boolean observingDataProfile;
-    void observeShowComments(LifecycleOwner owner, Observer<? super Boolean> observer) {
-        showComments.observe(owner, observer);
-    }
-    void observeBusyFlag(@NonNull LifecycleOwner owner, Observer<? super Boolean> observer) {
-        busyFlag.observe(owner, observer);
-    }
-    void observeDataProfile(LifecycleOwner activity) {
-        if (!observingDataProfile)
-            Data.observeProfile(activity, profileObserver);
-        observingDataProfile = true;
-    }
-    boolean getSimulateSave() {
-        return simulateSave.getValue();
-    }
-    public void setSimulateSave(boolean simulateSave) {
-        this.simulateSave.setValue(simulateSave);
-    }
-    void toggleSimulateSave() {
-        simulateSave.setValue(!simulateSave.getValue());
-    }
-    void observeSimulateSave(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
-                             @NonNull androidx.lifecycle.Observer<? super Boolean> observer) {
-        this.simulateSave.observe(owner, observer);
-    }
-    int getAccountCount() {
-        return items.size();
-    }
-    public SimpleDate getDate() {
-        return header.date.getValue();
-    }
-    public String getDescription() {
-        return header.description.getValue();
-    }
-    public String getComment() {
-        return header.comment.getValue();
-    }
-    LiveData<Boolean> isSubmittable() {
-        return this.isSubmittable;
-    }
-    void reset() {
-        header.date.setValue(null);
-        header.description.setValue(null);
-        header.comment.setValue(null);
-        items.clear();
-        items.add(new Item(this, new LedgerTransactionAccount("")));
-        items.add(new Item(this, new LedgerTransactionAccount("")));
-        focusedItem.setValue(0);
-    }
-    void observeFocusedItem(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
-                            @NonNull androidx.lifecycle.Observer<? super Integer> observer) {
-        this.focusedItem.observe(owner, observer);
-    }
-    void stopObservingFocusedItem(@NonNull androidx.lifecycle.Observer<? super Integer> observer) {
-        this.focusedItem.removeObserver(observer);
-    }
-    void observeAccountCount(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
-                             @NonNull androidx.lifecycle.Observer<? super Integer> observer) {
-        this.accountCount.observe(owner, observer);
-    }
-    void stopObservingAccountCount(@NonNull androidx.lifecycle.Observer<? super Integer> observer) {
-        this.accountCount.removeObserver(observer);
-    }
-    int getFocusedItem() { return focusedItem.getValue(); }
-    void setFocusedItem(int position) {
-        focusedItem.setValue(position);
-    }
-    int addAccount(LedgerTransactionAccount acc) {
-        items.add(new Item(this, acc));
-        accountCount.setValue(getAccountCount());
-        return items.size();
-    }
-    boolean accountsInInitialState() {
-        for (Item item : items) {
-            LedgerTransactionAccount acc = item.getAccount();
-            if (acc.isAmountSet())
-                return false;
-            if (!acc.getAccountName()
-                    .trim()
-                    .isEmpty())
-                return false;
-        }
-
-        return true;
-    }
-    LedgerTransactionAccount getAccount(int index) {
-        return items.get(index)
-                    .getAccount();
-    }
-    Item getItem(int index) {
-        if (index == 0) {
-            return header;
-        }
-
-        if (index <= items.size())
-            return items.get(index - 1);
-
-        return trailer;
-    }
-    void removeRow(Item item, NewTransactionItemsAdapter adapter) {
-        int pos = items.indexOf(item);
-        items.remove(pos);
-        if (adapter != null) {
-            adapter.notifyItemRemoved(pos + 1);
-            sendCountNotifications();
-        }
-    }
-    void removeItem(int pos) {
-        items.remove(pos);
-        accountCount.setValue(getAccountCount());
-    }
-    void sendCountNotifications() {
-        accountCount.setValue(getAccountCount());
-    }
-    public void sendFocusedNotification() {
-        focusedItem.setValue(focusedItem.getValue());
-    }
-    void updateFocusedItem(int position) {
-        focusedItem.setValue(position);
-    }
-    void noteFocusChanged(int position, FocusedElement element) {
-        getItem(position).setFocusedElement(element);
-    }
-    void swapItems(int one, int two) {
-        Collections.swap(items, one - 1, two - 1);
-    }
-    void moveItemLast(int index) {
-        /*   0
-             1   <-- index
-             2
-             3   <-- desired position
-         */
-        int itemCount = items.size();
-
-        if (index < itemCount - 1) {
-            Item acc = items.remove(index);
-            items.add(itemCount - 1, acc);
-        }
-    }
-    void toggleCurrencyVisible() {
-        showCurrency.setValue(!showCurrency.getValue());
-    }
-    void stopObservingBusyFlag(Observer<Boolean> observer) {
-        busyFlag.removeObserver(observer);
-    }
-    void incrementBusyCounter() {
-        int newValue = busyCounter.incrementAndGet();
-        if (newValue == 1)
-            busyFlag.postValue(true);
-    }
-    void decrementBusyCounter() {
-        int newValue = busyCounter.decrementAndGet();
-        if (newValue == 0)
-            busyFlag.postValue(false);
-    }
-    public boolean getBusyFlag() {
-        return busyFlag.getValue();
-    }
-    public void toggleShowComments() {
-        showComments.setValue(!showComments.getValue());
-    }
-    enum ItemType {generalData, transactionRow, bottomFiller}
-
-    enum FocusedElement {Account, Comment, Amount, Description, TransactionComment}
-
-
-    //==========================================================================================
-
-
-    static class Item {
-        private final ItemType type;
-        private final MutableLiveData<SimpleDate> date = new MutableLiveData<>();
-        private final MutableLiveData<String> description = new MutableLiveData<>();
-        private final MutableLiveData<String> amountHint = new MutableLiveData<>(null);
-        private final NewTransactionModel model;
-        private final MutableLiveData<Boolean> editable = new MutableLiveData<>(true);
-        private final MutableLiveData<String> comment = new MutableLiveData<>(null);
-        private final MutableLiveData<Currency> currency = new MutableLiveData<>(null);
-        private final MutableLiveData<Boolean> amountValid = new MutableLiveData<>(true);
-        private LedgerTransactionAccount account;
-        private FocusedElement focusedElement = FocusedElement.Account;
-        private boolean amountHintIsSet = false;
-        Item(NewTransactionModel model) {
-            this.model = model;
-            type = ItemType.bottomFiller;
-            editable.setValue(false);
-        }
-        Item(NewTransactionModel model, String description) {
-            this.model = model;
-            this.type = ItemType.generalData;
-            this.description.setValue(description);
-            this.editable.setValue(true);
-        }
-        Item(NewTransactionModel model, LedgerTransactionAccount account) {
-            this.model = model;
-            this.type = ItemType.transactionRow;
-            this.account = account;
-            String currName = account.getCurrency();
-            Currency curr = null;
-            if ((currName != null) && !currName.isEmpty())
-                curr = Currency.loadByName(currName);
-            this.currency.setValue(curr);
-            this.editable.setValue(true);
-        }
-        FocusedElement getFocusedElement() {
-            return focusedElement;
-        }
-        void setFocusedElement(FocusedElement focusedElement) {
-            this.focusedElement = focusedElement;
-        }
-        public NewTransactionModel getModel() {
-            return model;
-        }
-        void setEditable(boolean editable) {
-            ensureTypeIsGeneralDataOrTransactionRow();
-            this.editable.setValue(editable);
-        }
-        private void ensureTypeIsGeneralDataOrTransactionRow() {
-            if ((type != ItemType.generalData) && (type != ItemType.transactionRow)) {
-                throw new RuntimeException(
-                        String.format("Actual type (%s) differs from wanted (%s or %s)", type,
-                                ItemType.generalData, ItemType.transactionRow));
-            }
-        }
-        String getAmountHint() {
-            ensureType(ItemType.transactionRow);
-            return amountHint.getValue();
-        }
-        void setAmountHint(String amountHint) {
-            ensureType(ItemType.transactionRow);
-
-            // avoid unnecessary triggers
-            if (amountHint == null) {
-                if (this.amountHint.getValue() == null)
-                    return;
-                amountHintIsSet = false;
-            }
-            else {
-                if (amountHint.equals(this.amountHint.getValue()))
-                    return;
-                amountHintIsSet = true;
-            }
-
-            this.amountHint.setValue(amountHint);
-        }
-        void observeAmountHint(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
-                               @NonNull androidx.lifecycle.Observer<? super String> observer) {
-            this.amountHint.observe(owner, observer);
-        }
-        void stopObservingAmountHint(
-                @NonNull androidx.lifecycle.Observer<? super String> observer) {
-            this.amountHint.removeObserver(observer);
-        }
-        ItemType getType() {
-            return type;
-        }
-        void ensureType(ItemType wantedType) {
-            if (type != wantedType) {
-                throw new RuntimeException(
-                        String.format("Actual type (%s) differs from wanted (%s)", type,
-                                wantedType));
-            }
-        }
-        public SimpleDate getDate() {
-            ensureType(ItemType.generalData);
-            return date.getValue();
-        }
-        public void setDate(SimpleDate date) {
-            ensureType(ItemType.generalData);
-            this.date.setValue(date);
-        }
-        public void setDate(String text) throws ParseException {
-            if ((text == null) || text.trim()
-                                      .isEmpty())
-            {
-                setDate((SimpleDate) null);
-                return;
-            }
-
-            SimpleDate date = Globals.parseLedgerDate(text);
-            this.setDate(date);
-        }
-        void observeDate(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
-                         @NonNull androidx.lifecycle.Observer<? super SimpleDate> observer) {
-            this.date.observe(owner, observer);
-        }
-        void stopObservingDate(@NonNull androidx.lifecycle.Observer<? super SimpleDate> observer) {
-            this.date.removeObserver(observer);
-        }
-        public String getDescription() {
-            ensureType(ItemType.generalData);
-            return description.getValue();
-        }
-        public void setDescription(String description) {
-            ensureType(ItemType.generalData);
-            this.description.setValue(description);
-        }
-        void observeDescription(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
-                                @NonNull androidx.lifecycle.Observer<? super String> observer) {
-            this.description.observe(owner, observer);
-        }
-        void stopObservingDescription(
-                @NonNull androidx.lifecycle.Observer<? super String> observer) {
-            this.description.removeObserver(observer);
-        }
-        public String getTransactionComment() {
-            ensureType(ItemType.generalData);
-            return comment.getValue();
-        }
-        public void setTransactionComment(String transactionComment) {
-            ensureType(ItemType.generalData);
-            this.comment.setValue(transactionComment);
-        }
-        void observeTransactionComment(@NonNull @NotNull LifecycleOwner owner,
-                                       @NonNull Observer<? super String> observer) {
-            ensureType(ItemType.generalData);
-            this.comment.observe(owner, observer);
-        }
-        void stopObservingTransactionComment(@NonNull Observer<? super String> observer) {
-            this.comment.removeObserver(observer);
-        }
-        public LedgerTransactionAccount getAccount() {
-            ensureType(ItemType.transactionRow);
-            return account;
-        }
-        public void setAccountName(String name) {
-            account.setAccountName(name);
-        }
-        /**
-         * getFormattedDate()
-         *
-         * @return nicely formatted, shortest available date representation
-         */
-        String getFormattedDate() {
-            if (date == null)
-                return null;
-            SimpleDate d = date.getValue();
-            if (d == null)
-                return null;
-
-            Calendar today = GregorianCalendar.getInstance();
-
-            if (today.get(Calendar.YEAR) != d.year) {
-                return String.format(Locale.US, "%d/%02d/%02d", d.year, d.month, d.day);
-            }
-
-            if (today.get(Calendar.MONTH) != d.month - 1) {
-                return String.format(Locale.US, "%d/%02d", d.month, d.day);
-            }
-
-            return String.valueOf(d.day);
-        }
-        void observeEditableFlag(NewTransactionActivity activity, Observer<Boolean> observer) {
-            editable.observe(activity, observer);
-        }
-        void stopObservingEditableFlag(Observer<Boolean> observer) {
-            editable.removeObserver(observer);
-        }
-        void observeComment(NewTransactionActivity activity, Observer<String> observer) {
-            comment.observe(activity, observer);
-        }
-        void stopObservingComment(Observer<String> observer) {
-            comment.removeObserver(observer);
-        }
-        public void setComment(String comment) {
-            getAccount().setComment(comment);
-            this.comment.postValue(comment);
-        }
-        public Currency getCurrency() {
-            return this.currency.getValue();
-        }
-        public void setCurrency(Currency currency) {
-            Currency present = this.currency.getValue();
-            if ((currency == null) && (present != null) ||
-                (currency != null) && !currency.equals(present))
-            {
-                getAccount().setCurrency((currency != null && !currency.getName()
-                                                                       .isEmpty())
-                                         ? currency.getName() : null);
-                this.currency.setValue(currency);
-            }
-        }
-        void observeCurrency(NewTransactionActivity activity, Observer<Currency> observer) {
-            currency.observe(activity, observer);
-        }
-        void stopObservingCurrency(Observer<Currency> observer) {
-            currency.removeObserver(observer);
-        }
-        boolean isBottomFiller() {
-            return this.type == ItemType.bottomFiller;
-        }
-        boolean isAmountHintSet() {
-            return amountHintIsSet;
-        }
-        void validateAmount() {
-            amountValid.setValue(true);
-        }
-        void invalidateAmount() {
-            amountValid.setValue(false);
-        }
-        void observeAmountValidity(NewTransactionActivity activity, Observer<Boolean> observer) {
-            amountValid.observe(activity, observer);
-        }
-        void stopObservingAmountValidity(Observer<Boolean> observer) {
-            amountValid.removeObserver(observer);
-        }
-    }
-}
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/activity/ProfileDetailActivity.java b/app/src/main/java/net/ktnx/mobileledger/ui/activity/ProfileDetailActivity.java
deleted file mode 100644 (file)
index 1bc17ec..0000000
+++ /dev/null
@@ -1,133 +0,0 @@
-/*
- * Copyright © 2019 Damyan Ivanov.
- * This file is part of MoLe.
- * MoLe is free software: you can distribute it and/or modify it
- * under the term of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your opinion), any later version.
- *
- * MoLe is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License terms for details.
- *
- * You should have received a copy of the GNU General Public License
- * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package net.ktnx.mobileledger.ui.activity;
-
-import android.os.Bundle;
-import android.view.Menu;
-import android.view.MenuItem;
-
-import androidx.appcompat.app.ActionBar;
-import androidx.appcompat.widget.Toolbar;
-import androidx.lifecycle.ViewModelProvider;
-
-import net.ktnx.mobileledger.R;
-import net.ktnx.mobileledger.model.Data;
-import net.ktnx.mobileledger.model.MobileLedgerProfile;
-import net.ktnx.mobileledger.ui.profiles.ProfileDetailFragment;
-import net.ktnx.mobileledger.ui.profiles.ProfileDetailModel;
-import net.ktnx.mobileledger.utils.Colors;
-
-import org.jetbrains.annotations.NotNull;
-
-import java.util.ArrayList;
-import java.util.Locale;
-
-import static net.ktnx.mobileledger.utils.Logger.debug;
-
-/**
- * An activity representing a single Profile detail screen. This
- * activity is only used on narrow width devices. On tablet-size devices,
- * item details are presented side-by-side with a list of items
- * in a ProfileListActivity (not really).
- */
-public class ProfileDetailActivity extends CrashReportingActivity {
-    private MobileLedgerProfile profile = null;
-    private ProfileDetailFragment mFragment;
-    @NotNull
-    private ProfileDetailModel getModel() {
-        return new ViewModelProvider(this).get(ProfileDetailModel.class);
-    }
-    @Override
-    protected void onCreate(Bundle savedInstanceState) {
-        final int index = getIntent().getIntExtra(ProfileDetailFragment.ARG_ITEM_ID, -1);
-
-        if (index != -1) {
-            ArrayList<MobileLedgerProfile> profiles = Data.profiles.getValue();
-            if (profiles != null) {
-                profile = profiles.get(index);
-                if (profile == null)
-                    throw new AssertionError(
-                            String.format("Can't get profile " + "(index:%d) from the global list",
-                                    index));
-
-                debug("profiles", String.format(Locale.ENGLISH, "Editing profile %s (%s); hue=%d",
-                        profile.getName(), profile.getUuid(), profile.getThemeHue()));
-            }
-        }
-
-        super.onCreate(savedInstanceState);
-        int themeHue;
-        if (profile != null)
-            themeHue = profile.getThemeHue();
-        else {
-            themeHue = Colors.getNewProfileThemeHue(Data.profiles.getValue());
-        }
-        Colors.setupTheme(this, themeHue);
-        final ProfileDetailModel model = getModel();
-        model.initialThemeHue = themeHue;
-        setContentView(R.layout.activity_profile_detail);
-        Toolbar toolbar = findViewById(R.id.detail_toolbar);
-        setSupportActionBar(toolbar);
-
-
-        // Show the Up button in the action bar.
-        ActionBar actionBar = getSupportActionBar();
-        if (actionBar != null) {
-            actionBar.setDisplayHomeAsUpEnabled(true);
-        }
-
-        // savedInstanceState is non-null when there is fragment state
-        // saved from previous configurations of this activity
-        // (e.g. when rotating the screen from portrait to landscape).
-        // In this case, the fragment will automatically be re-added
-        // to its container so we don't need to manually add it.
-        // For more information, see the Fragments API guide at:
-        //
-        // http://developer.android.com/guide/components/fragments.html
-        //
-        if (savedInstanceState == null) {
-            // Create the detail fragment and add it to the activity
-            // using a fragment transaction.
-            Bundle arguments = new Bundle();
-            arguments.putInt(ProfileDetailFragment.ARG_ITEM_ID, index);
-            arguments.putInt(ProfileDetailFragment.ARG_HUE, themeHue);
-            mFragment = new ProfileDetailFragment();
-            mFragment.setArguments(arguments);
-            getSupportFragmentManager().beginTransaction()
-                                       .add(R.id.profile_detail_container, mFragment)
-                                       .commit();
-        }
-    }
-    @Override
-    public boolean onCreateOptionsMenu(Menu menu) {
-        super.onCreateOptionsMenu(menu);
-        debug("profiles", "[activity] Creating profile details options menu");
-        if (mFragment != null)
-            mFragment.onCreateOptionsMenu(menu, getMenuInflater());
-
-        return true;
-    }
-    @Override
-    public boolean onOptionsItemSelected(MenuItem item) {
-        if (item.getItemId() == android.R.id.home) {
-            finish();
-            return true;
-        }
-        return super.onOptionsItemSelected(item);
-    }
-}
index 81450c9a13bedc9b0fe6e9cf17d8800a7cf1702b..b9726492844cfd959cb208ae6258a423fb1ebd52 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2020 Damyan Ivanov.
+ * Copyright © 2021 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
@@ -22,16 +22,50 @@ import android.os.Bundle;
 
 import androidx.annotation.Nullable;
 
+import net.ktnx.mobileledger.App;
+import net.ktnx.mobileledger.dao.BaseDAO;
+import net.ktnx.mobileledger.dao.ProfileDAO;
+import net.ktnx.mobileledger.db.DB;
+import net.ktnx.mobileledger.db.Profile;
 import net.ktnx.mobileledger.model.Data;
-import net.ktnx.mobileledger.model.MobileLedgerProfile;
 import net.ktnx.mobileledger.utils.Colors;
+import net.ktnx.mobileledger.utils.Logger;
+
+import java.util.Locale;
 
 @SuppressLint("Registered")
 public class ProfileThemedActivity extends CrashReportingActivity {
-    protected MobileLedgerProfile mProfile;
-    protected void setupProfileColors() {
-        final int themeHue = (mProfile == null) ? -1 : mProfile.getThemeHue();
-        Colors.setupTheme(this, themeHue);
+    public static final String TAG = "prf-thm-act";
+    protected static final String PARAM_PROFILE_ID = "profile-id";
+    protected static final String PARAM_THEME = "theme";
+    protected Profile mProfile;
+    private boolean themeSetUp = false;
+    private boolean mIgnoreProfileChange;
+    private int mThemeHue;
+    protected void setupProfileColors(int newHue) {
+        if (themeSetUp && newHue == mThemeHue) {
+            Logger.debug(TAG,
+                    String.format(Locale.ROOT, "Ignore request to set theme to the same value (%d)",
+                            newHue));
+            return;
+        }
+
+        Logger.debug(TAG,
+                String.format(Locale.ROOT, "Changing theme from %d to %d", mThemeHue, newHue));
+
+        mThemeHue = newHue;
+        Colors.setupTheme(this, mThemeHue);
+
+        if (themeSetUp) {
+            Logger.debug(TAG,
+                    "setupProfileColors(): theme already set up, supposedly the activity will be " +
+                    "recreated");
+//            this.recreate();
+            return;
+        }
+        themeSetUp = true;
+
+        Colors.profileThemeId = mThemeHue;
     }
     @Override
     protected void onStart() {
@@ -40,10 +74,62 @@ public class ProfileThemedActivity extends CrashReportingActivity {
     }
     protected void onCreate(@Nullable Bundle savedInstanceState) {
         initProfile();
-        setupProfileColors();
+
+        Data.observeProfile(this, profile -> {
+            if (profile == null) {
+                Logger.debug(TAG, "No current profile, leaving");
+                return;
+            }
+
+            mProfile = profile;
+            storeProfilePref(profile);
+            int hue = profile.getTheme();
+
+            if (hue != mThemeHue) {
+                Logger.debug(TAG,
+                        String.format(Locale.US, "profile observer calling setupProfileColors(%d)",
+                                hue));
+                setupProfileColors(hue);
+            }
+        });
+
         super.onCreate(savedInstanceState);
     }
+    public void storeProfilePref(Profile profile) {
+        App.storeStartupProfileAndTheme(profile.getId(), profile.getTheme());
+    }
     protected void initProfile() {
-        mProfile = Data.initProfile();
+        long profileId = App.getStartupProfile();
+        int hue = App.getStartupTheme();
+        if (profileId == -1)
+            mThemeHue = Colors.DEFAULT_HUE_DEG;
+
+        Logger.debug(TAG,
+                String.format(Locale.US, "initProfile() calling setupProfileColors(%d)", hue));
+        setupProfileColors(hue);
+
+        initProfile(profileId);
+    }
+    protected void initProfile(long profileId) {
+        BaseDAO.runAsync(() -> initProfileSync(profileId));
+    }
+    private void initProfileSync(long profileId) {
+        Logger.debug(TAG, String.format(Locale.US, "Loading profile %d", profileId));
+        ProfileDAO dao = DB.get()
+                           .getProfileDAO();
+        Profile profile = dao.getByIdSync(profileId);
+
+        if (profile == null) {
+            Logger.debug(TAG, String.format(Locale.ROOT, "Profile %d not found. Trying any other",
+                    profileId));
+
+            profile = dao.getAnySync();
+        }
+
+        if (profile == null)
+            Logger.debug(TAG, "No profile could be loaded");
+        else
+            Logger.debug(TAG, String.format(Locale.ROOT, "Profile %d loaded. posting", profileId));
+        Data.postCurrentProfile(profile);
     }
 }
index 4988d1c5a6d6dd80f8cde0dd9b38a94809a7f201..3fe204834bb572c05e7e77a8caf5a1687f15699f 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2020 Damyan Ivanov.
+ * Copyright © 2021 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
 package net.ktnx.mobileledger.ui.activity;
 
 import android.content.Intent;
-import android.os.AsyncTask;
 import android.os.Bundle;
 import android.os.Handler;
+import android.os.Looper;
 
 import androidx.annotation.Nullable;
 
 import net.ktnx.mobileledger.R;
-import net.ktnx.mobileledger.model.Data;
-import net.ktnx.mobileledger.model.MobileLedgerProfile;
+import net.ktnx.mobileledger.db.DB;
 import net.ktnx.mobileledger.utils.Logger;
-import net.ktnx.mobileledger.utils.MLDB;
-import net.ktnx.mobileledger.utils.MobileLedgerDatabase;
+
+import java.util.Locale;
 
 public class SplashActivity extends CrashReportingActivity {
-    private static final long keepActiveForMS = 500;
+    private static final long keepActiveForMS = 400;
     private long startupTime;
     private boolean running = true;
     @Override
@@ -42,8 +41,8 @@ public class SplashActivity extends CrashReportingActivity {
         setContentView(R.layout.splash_activity_layout);
         Logger.debug("splash", "onCreate()");
 
-        MobileLedgerDatabase.initComplete.setValue(false);
-        MobileLedgerDatabase.initComplete.observe(this, this::onDbInitDoneChanged);
+        DB.initComplete.setValue(false);
+        DB.initComplete.observe(this, this::onDbInitDoneChanged);
     }
     @Override
     protected void onStart() {
@@ -53,8 +52,9 @@ public class SplashActivity extends CrashReportingActivity {
 
         startupTime = System.currentTimeMillis();
 
-        AsyncTask<Void, Void, Void> dbInitTask = new DatabaseInitTask();
-        dbInitTask.execute();
+        DatabaseInitThread dbInitThread = new DatabaseInitThread();
+        Logger.debug("splash", "starting dbInit task");
+        dbInitThread.start();
     }
     @Override
     protected void onPause() {
@@ -79,8 +79,11 @@ public class SplashActivity extends CrashReportingActivity {
         if (now > startupTime + keepActiveForMS)
             startMainActivity();
         else {
-            new Handler().postDelayed(this::startMainActivity,
-                    keepActiveForMS - (now - startupTime));
+            final long delay = keepActiveForMS - (now - startupTime);
+            Logger.debug("splash",
+                    String.format(Locale.ROOT, "Scheduling main activity start in %d milliseconds",
+                            delay));
+            new Handler(Looper.getMainLooper()).postDelayed(this::startMainActivity, delay);
         }
     }
     private void startMainActivity() {
@@ -97,22 +100,14 @@ public class SplashActivity extends CrashReportingActivity {
             finish();
         }
     }
-    private static class DatabaseInitTask extends AsyncTask<Void, Void, Void> {
+    private static class DatabaseInitThread extends Thread {
         @Override
-        protected Void doInBackground(Void... voids) {
-            MobileLedgerProfile.loadAllFromDB(null);
+        public void run() {
+            long ignored = DB.get()
+                             .getProfileDAO()
+                             .getProfileCountSync();
 
-            String profileUUID = MLDB.getOption(MLDB.OPT_PROFILE_UUID, null);
-            MobileLedgerProfile startupProfile = Data.getProfile(profileUUID);
-            if (startupProfile != null)
-                Data.setCurrentProfile(startupProfile);
-            return null;
-        }
-        @Override
-        protected void onPostExecute(Void aVoid) {
-            Logger.debug("splash", "DatabaseInitTask::onPostExecute()");
-            super.onPostExecute(aVoid);
-            MobileLedgerDatabase.initComplete.setValue(true);
+            DB.initComplete.postValue(true);
         }
     }
 }
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionAccountRowItemHolder.java b/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionAccountRowItemHolder.java
new file mode 100644 (file)
index 0000000..8f1c51d
--- /dev/null
@@ -0,0 +1,538 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui.new_transaction;
+
+import android.annotation.SuppressLint;
+import android.graphics.Typeface;
+import android.text.Editable;
+import android.text.TextUtils;
+import android.text.TextWatcher;
+import android.view.Gravity;
+import android.view.View;
+import android.view.inputmethod.EditorInfo;
+import android.widget.EditText;
+import android.widget.TextView;
+
+import androidx.annotation.ColorInt;
+import androidx.annotation.NonNull;
+import androidx.constraintlayout.widget.ConstraintLayout;
+import androidx.recyclerview.widget.RecyclerView;
+
+import net.ktnx.mobileledger.R;
+import net.ktnx.mobileledger.databinding.NewTransactionAccountRowBinding;
+import net.ktnx.mobileledger.db.AccountWithAmountsAutocompleteAdapter;
+import net.ktnx.mobileledger.model.Currency;
+import net.ktnx.mobileledger.model.Data;
+import net.ktnx.mobileledger.ui.CurrencySelectorFragment;
+import net.ktnx.mobileledger.ui.TextViewClearHelper;
+import net.ktnx.mobileledger.utils.DimensionUtils;
+import net.ktnx.mobileledger.utils.Logger;
+import net.ktnx.mobileledger.utils.Misc;
+
+import java.text.ParseException;
+
+class NewTransactionAccountRowItemHolder extends NewTransactionItemViewHolder {
+    private final NewTransactionAccountRowBinding b;
+    private boolean ignoreFocusChanges = false;
+    private boolean inUpdate = false;
+    private boolean syncingData = false;
+    NewTransactionAccountRowItemHolder(@NonNull NewTransactionAccountRowBinding b,
+                                       NewTransactionItemsAdapter adapter) {
+        super(b.getRoot());
+        this.b = b;
+        new TextViewClearHelper().attachToTextView(b.comment);
+
+        b.accountRowAccName.setNextFocusForwardId(View.NO_ID);
+        b.accountRowAccAmounts.setNextFocusForwardId(View.NO_ID); // magic!
+
+        b.accountCommentButton.setOnClickListener(v -> {
+            b.comment.setVisibility(View.VISIBLE);
+            b.comment.requestFocus();
+        });
+
+        @SuppressLint("DefaultLocale") View.OnFocusChangeListener focusMonitor = (v, hasFocus) -> {
+            final int id = v.getId();
+            if (hasFocus) {
+                boolean wasSyncing = syncingData;
+                syncingData = true;
+                try {
+                    final int pos = getBindingAdapterPosition();
+                    if (id == R.id.account_row_acc_name) {
+                        adapter.noteFocusIsOnAccount(pos);
+                    }
+                    else if (id == R.id.account_row_acc_amounts) {
+                        adapter.noteFocusIsOnAmount(pos);
+                    }
+                    else if (id == R.id.comment) {
+                        adapter.noteFocusIsOnComment(pos);
+                    }
+                    else
+                        throw new IllegalStateException("Where is the focus? " + id);
+                }
+                finally {
+                    syncingData = wasSyncing;
+                }
+            }
+            else {  // lost focus
+                if (id == R.id.account_row_acc_amounts) {
+                    try {
+                        String input = String.valueOf(b.accountRowAccAmounts.getText());
+                        input = input.replace(Data.getDecimalSeparator(), Data.decimalDot);
+                        final String newText = Data.formatNumber(Float.parseFloat(input));
+                        if (!newText.equals(input)) {
+                            boolean wasSyncingData = syncingData;
+                            syncingData = true;
+                            try {
+                                // there is a listener that will propagate the change to the model
+                                b.accountRowAccAmounts.setText(newText);
+                            }
+                            finally {
+                                syncingData = wasSyncingData;
+                            }
+                        }
+                    }
+                    catch (NumberFormatException ex) {
+                        // ignored
+                    }
+                }
+            }
+
+            if (id == R.id.comment) {
+                commentFocusChanged(b.comment, hasFocus);
+            }
+        };
+
+        b.accountRowAccName.setOnFocusChangeListener(focusMonitor);
+        b.accountRowAccAmounts.setOnFocusChangeListener(focusMonitor);
+        b.comment.setOnFocusChangeListener(focusMonitor);
+
+        NewTransactionActivity activity = (NewTransactionActivity) b.getRoot()
+                                                                    .getContext();
+
+        b.accountRowAccName.setAdapter(new AccountWithAmountsAutocompleteAdapter(b.getRoot()
+                                                                                  .getContext(),
+                mProfile));
+        b.accountRowAccName.setOnItemClickListener((parent, view, position, id) -> {
+            adapter.noteFocusIsOnAmount(position);
+        });
+
+        final TextWatcher tw = new TextWatcher() {
+            @Override
+            public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+            }
+
+            @Override
+            public void onTextChanged(CharSequence s, int start, int before, int count) {
+            }
+
+            @Override
+            public void afterTextChanged(Editable s) {
+//                debug("input", "text changed");
+                if (inUpdate)
+                    return;
+
+                Logger.debug("textWatcher", "calling syncData()");
+                if (syncData()) {
+                    Logger.debug("textWatcher",
+                            "syncData() returned, checking if transaction is submittable");
+                    adapter.model.checkTransactionSubmittable(null);
+                }
+                Logger.debug("textWatcher", "done");
+            }
+        };
+        final TextWatcher amountWatcher = new TextWatcher() {
+            @Override
+            public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
+            @Override
+            public void onTextChanged(CharSequence s, int start, int before, int count) {}
+            @Override
+            public void afterTextChanged(Editable s) {
+                checkAmountValid(s.toString());
+
+                if (syncData())
+                    adapter.model.checkTransactionSubmittable(null);
+            }
+        };
+        b.accountRowAccName.addTextChangedListener(tw);
+        monitorComment(b.comment);
+        b.accountRowAccAmounts.addTextChangedListener(amountWatcher);
+
+        b.currencyButton.setOnClickListener(v -> {
+            CurrencySelectorFragment cpf = new CurrencySelectorFragment();
+            cpf.showPositionAndPadding();
+            cpf.setOnCurrencySelectedListener(
+                    c -> adapter.setItemCurrency(getBindingAdapterPosition(), c));
+            cpf.show(activity.getSupportFragmentManager(), "currency-selector");
+        });
+
+        commentFocusChanged(b.comment, false);
+
+        adapter.model.getFocusInfo()
+                     .observe(activity, this::applyFocus);
+
+        Data.currencyGap.observe(activity,
+                hasGap -> updateCurrencyPositionAndPadding(Data.currencySymbolPosition.getValue(),
+                        hasGap));
+
+        Data.currencySymbolPosition.observe(activity,
+                position -> updateCurrencyPositionAndPadding(position,
+                        Data.currencyGap.getValue()));
+
+        adapter.model.getShowCurrency()
+                     .observe(activity, showCurrency -> {
+                         if (showCurrency) {
+                             b.currency.setVisibility(View.VISIBLE);
+                             b.currencyButton.setVisibility(View.VISIBLE);
+                             setCurrencyString(mProfile.getDefaultCommodity());
+                         }
+                         else {
+                             b.currency.setVisibility(View.GONE);
+                             b.currencyButton.setVisibility(View.GONE);
+                             setCurrencyString(null);
+                         }
+                     });
+
+        adapter.model.getShowComments()
+                     .observe(activity, show -> b.commentLayout.setVisibility(
+                             show ? View.VISIBLE : View.GONE));
+    }
+    private void applyFocus(NewTransactionModel.FocusInfo focusInfo) {
+        if (ignoreFocusChanges) {
+            Logger.debug("new-trans", "Ignoring focus change");
+            return;
+        }
+        ignoreFocusChanges = true;
+        try {
+            if (((focusInfo == null) || (focusInfo.element == null) ||
+                 focusInfo.position != getBindingAdapterPosition()))
+                return;
+
+            final NewTransactionModel.Item item = getItem();
+            if (item == null)
+                return;
+
+            NewTransactionModel.TransactionAccount acc = item.toTransactionAccount();
+            switch (focusInfo.element) {
+                case Amount:
+                    b.accountRowAccAmounts.requestFocus();
+                    break;
+                case Comment:
+                    b.comment.setVisibility(View.VISIBLE);
+                    b.comment.requestFocus();
+                    break;
+                case Account:
+                    boolean focused = b.accountRowAccName.requestFocus();
+//                                         b.accountRowAccName.dismissDropDown();
+                    if (focused)
+                        Misc.showSoftKeyboard((NewTransactionActivity) b.getRoot()
+                                                                        .getContext());
+                    break;
+            }
+        }
+        finally {
+            ignoreFocusChanges = false;
+        }
+    }
+    public void checkAmountValid(String s) {
+        // FIXME this needs to be done in the model only
+        boolean valid = true;
+        try {
+            if (s.length() > 0) {
+                float ignored =
+                        Float.parseFloat(s.replace(Data.getDecimalSeparator(), Data.decimalDot));
+            }
+        }
+        catch (NumberFormatException ex) {
+            try {
+                float ignored = Data.parseNumber(s);
+            }
+            catch (ParseException ex2) {
+                valid = false;
+            }
+        }
+
+        displayAmountValidity(valid);
+    }
+    private void displayAmountValidity(boolean valid) {
+        b.accountRowAccAmounts.setCompoundDrawablesRelativeWithIntrinsicBounds(
+                valid ? 0 : R.drawable.ic_error_outline_black_24dp, 0, 0, 0);
+        b.accountRowAccAmounts.setMinEms(valid ? 4 : 5);
+    }
+    private void monitorComment(EditText editText) {
+        editText.addTextChangedListener(new TextWatcher() {
+            @Override
+            public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+            }
+            @Override
+            public void onTextChanged(CharSequence s, int start, int before, int count) {
+            }
+            @Override
+            public void afterTextChanged(Editable s) {
+//                debug("input", "text changed");
+                if (inUpdate)
+                    return;
+
+                Logger.debug("textWatcher", "calling syncData()");
+                syncData();
+                Logger.debug("textWatcher",
+                        "syncData() returned, checking if transaction is submittable");
+                styleComment(editText, s.toString());
+                Logger.debug("textWatcher", "done");
+            }
+        });
+    }
+    private void commentFocusChanged(TextView textView, boolean hasFocus) {
+        @ColorInt int textColor;
+        textColor = b.dummyText.getTextColors()
+                               .getDefaultColor();
+        if (hasFocus) {
+            textView.setTypeface(null, Typeface.NORMAL);
+            textView.setHint(R.string.transaction_account_comment_hint);
+        }
+        else {
+            int alpha = (textColor >> 24 & 0xff);
+            alpha = 3 * alpha / 4;
+            textColor = (alpha << 24) | (0x00ffffff & textColor);
+            textView.setTypeface(null, Typeface.ITALIC);
+            textView.setHint("");
+            if (TextUtils.isEmpty(textView.getText())) {
+                textView.setVisibility(View.INVISIBLE);
+            }
+        }
+        textView.setTextColor(textColor);
+
+    }
+    private void updateCurrencyPositionAndPadding(Currency.Position position, boolean hasGap) {
+        ConstraintLayout.LayoutParams amountLP =
+                (ConstraintLayout.LayoutParams) b.accountRowAccAmounts.getLayoutParams();
+        ConstraintLayout.LayoutParams currencyLP =
+                (ConstraintLayout.LayoutParams) b.currency.getLayoutParams();
+
+        if (position == Currency.Position.before) {
+            currencyLP.startToStart = ConstraintLayout.LayoutParams.PARENT_ID;
+            currencyLP.endToEnd = ConstraintLayout.LayoutParams.UNSET;
+
+            amountLP.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID;
+            amountLP.endToStart = ConstraintLayout.LayoutParams.UNSET;
+            amountLP.startToStart = ConstraintLayout.LayoutParams.UNSET;
+            amountLP.startToEnd = b.currency.getId();
+
+            b.currency.setGravity(Gravity.END);
+        }
+        else {
+            currencyLP.startToStart = ConstraintLayout.LayoutParams.UNSET;
+            currencyLP.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID;
+
+            amountLP.startToStart = ConstraintLayout.LayoutParams.PARENT_ID;
+            amountLP.startToEnd = ConstraintLayout.LayoutParams.UNSET;
+            amountLP.endToEnd = ConstraintLayout.LayoutParams.UNSET;
+            amountLP.endToStart = b.currency.getId();
+
+            b.currency.setGravity(Gravity.START);
+        }
+
+        amountLP.resolveLayoutDirection(b.accountRowAccAmounts.getLayoutDirection());
+        currencyLP.resolveLayoutDirection(b.currency.getLayoutDirection());
+
+        b.accountRowAccAmounts.setLayoutParams(amountLP);
+        b.currency.setLayoutParams(currencyLP);
+
+        // distance between the amount and the currency symbol
+        int gapSize = DimensionUtils.sp2px(b.currency.getContext(), 5);
+
+        if (position == Currency.Position.before) {
+            b.currency.setPaddingRelative(0, 0, hasGap ? gapSize : 0, 0);
+        }
+        else {
+            b.currency.setPaddingRelative(hasGap ? gapSize : 0, 0, 0, 0);
+        }
+    }
+    private void setCurrencyString(String currency) {
+        @ColorInt int textColor = b.dummyText.getTextColors()
+                                             .getDefaultColor();
+        if (TextUtils.isEmpty(currency)) {
+            b.currency.setText(R.string.currency_symbol);
+            int alpha = (textColor >> 24) & 0xff;
+            alpha = alpha * 3 / 4;
+            b.currency.setTextColor((alpha << 24) | (0x00ffffff & textColor));
+        }
+        else {
+            b.currency.setText(currency);
+            b.currency.setTextColor(textColor);
+        }
+    }
+    private void setCurrency(Currency currency) {
+        setCurrencyString((currency == null) ? null : currency.getName());
+    }
+    private void setEditable(Boolean editable) {
+        b.accountRowAccName.setEnabled(editable);
+        b.accountRowAccAmounts.setEnabled(editable);
+    }
+    private void beginUpdates() {
+        if (inUpdate)
+            throw new RuntimeException("Already in update mode");
+        inUpdate = true;
+    }
+    private void endUpdates() {
+        if (!inUpdate)
+            throw new RuntimeException("Not in update mode");
+        inUpdate = false;
+    }
+    /**
+     * syncData()
+     * <p>
+     * Stores the data from the UI elements into the model item
+     * Returns true if there were changes made that suggest transaction has to be
+     * checked for being submittable
+     */
+    private boolean syncData() {
+        if (syncingData) {
+            Logger.debug("new-trans", "skipping syncData() loop");
+            return false;
+        }
+
+        if (getBindingAdapterPosition() == RecyclerView.NO_POSITION) {
+            // probably the row was swiped out
+            Logger.debug("new-trans", "Ignoring request to syncData(): adapter position negative");
+            return false;
+        }
+
+        final NewTransactionModel.Item item = getItem();
+        if (item == null)
+            return false;
+
+        syncingData = true;
+
+        boolean significantChange = false;
+
+        try {
+            NewTransactionModel.TransactionAccount acc = item.toTransactionAccount();
+
+            // having account name is important
+            final Editable incomingAccountName = b.accountRowAccName.getText();
+            if (TextUtils.isEmpty(acc.getAccountName()) != TextUtils.isEmpty(incomingAccountName))
+                significantChange = true;
+
+            acc.setAccountName(String.valueOf(incomingAccountName));
+            final int accNameSelEnd = b.accountRowAccName.getSelectionEnd();
+            final int accNameSelStart = b.accountRowAccName.getSelectionStart();
+            acc.setAccountNameCursorPosition(accNameSelEnd);
+
+            acc.setComment(String.valueOf(b.comment.getText()));
+
+            String amount = String.valueOf(b.accountRowAccAmounts.getText());
+
+            if (acc.setAndCheckAmountText(Misc.nullIsEmpty(amount)))
+                significantChange = true;
+            displayAmountValidity(!acc.isAmountSet() || acc.isAmountValid());
+
+            final String curr = String.valueOf(b.currency.getText());
+            final String currValue;
+            if (curr.equals(b.currency.getContext()
+                                      .getResources()
+                                      .getString(R.string.currency_symbol)) || curr.isEmpty())
+                currValue = null;
+            else
+                currValue = curr;
+
+            if (!significantChange && !Misc.equalStrings(acc.getCurrency(), currValue))
+                significantChange = true;
+            acc.setCurrency(currValue);
+
+            return significantChange;
+        }
+        finally {
+            syncingData = false;
+        }
+    }
+    /**
+     * bind
+     *
+     * @param item updates the UI elements with the data from the model item
+     */
+    @SuppressLint("DefaultLocale")
+    public void bind(@NonNull NewTransactionModel.Item item) {
+        beginUpdates();
+        try {
+            syncingData = true;
+            try {
+                NewTransactionModel.TransactionAccount acc = item.toTransactionAccount();
+
+                final String incomingAccountName = acc.getAccountName();
+                final String presentAccountName = String.valueOf(b.accountRowAccName.getText());
+                if (!Misc.equalStrings(incomingAccountName, presentAccountName)) {
+                    Logger.debug("bind",
+                            String.format("Setting account name from '%s' to '%s' (| @ %d)",
+                                    presentAccountName, incomingAccountName,
+                                    acc.getAccountNameCursorPosition()));
+                    // avoid triggering completion pop-up
+                    AccountWithAmountsAutocompleteAdapter a =
+                            (AccountWithAmountsAutocompleteAdapter) b.accountRowAccName.getAdapter();
+                    try {
+                        b.accountRowAccName.setAdapter(null);
+                        b.accountRowAccName.setText(incomingAccountName);
+                        b.accountRowAccName.setSelection(acc.getAccountNameCursorPosition());
+                    }
+                    finally {
+                        b.accountRowAccName.setAdapter(a);
+                    }
+                }
+
+                final String amountHint = acc.getAmountHint();
+                if (amountHint == null) {
+                    b.accountRowAccAmounts.setHint(R.string.zero_amount);
+                }
+                else {
+                    b.accountRowAccAmounts.setHint(amountHint);
+                }
+
+                b.accountRowAccAmounts.setImeOptions(
+                        acc.isLast() ? EditorInfo.IME_ACTION_DONE : EditorInfo.IME_ACTION_NEXT);
+
+                setCurrencyString(acc.getCurrency());
+                b.accountRowAccAmounts.setText(acc.getAmountText());
+                displayAmountValidity(!acc.isAmountSet() || acc.isAmountValid());
+
+                final String comment = acc.getComment();
+                b.comment.setText(comment);
+                styleComment(b.comment, comment);
+
+                setEditable(true);
+
+                NewTransactionItemsAdapter adapter =
+                        (NewTransactionItemsAdapter) getBindingAdapter();
+                if (adapter != null)
+                    applyFocus(adapter.model.getFocusInfo()
+                                            .getValue());
+            }
+            finally {
+                syncingData = false;
+            }
+        }
+        finally {
+            endUpdates();
+        }
+    }
+    private void styleComment(EditText editText, String comment) {
+        final View focusedView = editText.findFocus();
+        editText.setTypeface(null, (focusedView == editText) ? Typeface.NORMAL : Typeface.ITALIC);
+        editText.setVisibility(
+                ((focusedView != editText) && TextUtils.isEmpty(comment)) ? View.INVISIBLE
+                                                                          : View.VISIBLE);
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionActivity.java b/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionActivity.java
new file mode 100644 (file)
index 0000000..a1d9e74
--- /dev/null
@@ -0,0 +1,408 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui.new_transaction;
+
+import android.content.Context;
+import android.content.Intent;
+import android.database.AbstractCursor;
+import android.os.Bundle;
+import android.os.ParcelFormatException;
+import android.util.TypedValue;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.core.view.MenuCompat;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.navigation.NavController;
+import androidx.navigation.fragment.NavHostFragment;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import net.ktnx.mobileledger.BuildConfig;
+import net.ktnx.mobileledger.R;
+import net.ktnx.mobileledger.async.DescriptionSelectedCallback;
+import net.ktnx.mobileledger.async.GeneralBackgroundTasks;
+import net.ktnx.mobileledger.async.SendTransactionTask;
+import net.ktnx.mobileledger.async.TaskCallback;
+import net.ktnx.mobileledger.dao.BaseDAO;
+import net.ktnx.mobileledger.dao.TransactionDAO;
+import net.ktnx.mobileledger.databinding.ActivityNewTransactionBinding;
+import net.ktnx.mobileledger.db.DB;
+import net.ktnx.mobileledger.db.TemplateHeader;
+import net.ktnx.mobileledger.db.TransactionWithAccounts;
+import net.ktnx.mobileledger.model.Data;
+import net.ktnx.mobileledger.model.LedgerTransaction;
+import net.ktnx.mobileledger.model.MatchedTemplate;
+import net.ktnx.mobileledger.ui.FabManager;
+import net.ktnx.mobileledger.ui.QR;
+import net.ktnx.mobileledger.ui.activity.ProfileThemedActivity;
+import net.ktnx.mobileledger.ui.activity.SplashActivity;
+import net.ktnx.mobileledger.ui.templates.TemplatesActivity;
+import net.ktnx.mobileledger.utils.Logger;
+import net.ktnx.mobileledger.utils.Misc;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static net.ktnx.mobileledger.utils.Logger.debug;
+
+public class NewTransactionActivity extends ProfileThemedActivity
+        implements TaskCallback, NewTransactionFragment.OnNewTransactionFragmentInteractionListener,
+        QR.QRScanTrigger, QR.QRScanResultReceiver, DescriptionSelectedCallback,
+        FabManager.FabHandler {
+    final String TAG = "new-t-a";
+    private NavController navController;
+    private NewTransactionModel model;
+    private ActivityResultLauncher<Void> qrScanLauncher;
+    private ActivityNewTransactionBinding b;
+    private FabManager fabManager;
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+
+        b = ActivityNewTransactionBinding.inflate(getLayoutInflater(), null, false);
+        setContentView(b.getRoot());
+        setSupportActionBar(b.toolbar);
+        Data.observeProfile(this, profile -> {
+            if (profile == null) {
+                Logger.debug("new-t-act", "no active profile. Redirecting to SplashActivity");
+                Intent intent = new Intent(this, SplashActivity.class);
+                intent.setFlags(Intent.FLAG_ACTIVITY_TASK_ON_HOME | Intent.FLAG_ACTIVITY_NEW_TASK);
+                startActivity(intent);
+                finish();
+            }
+            else
+                b.toolbar.setSubtitle(profile.getName());
+        });
+
+        NavHostFragment navHostFragment = (NavHostFragment) Objects.requireNonNull(
+                getSupportFragmentManager().findFragmentById(R.id.new_transaction_nav));
+        navController = navHostFragment.getNavController();
+
+        Objects.requireNonNull(getSupportActionBar())
+               .setDisplayHomeAsUpEnabled(true);
+
+        model = new ViewModelProvider(this).get(NewTransactionModel.class);
+
+        qrScanLauncher = QR.registerLauncher(this, this);
+
+        fabManager = new FabManager(b.fabAdd);
+
+        model.isSubmittable()
+             .observe(this, isSubmittable -> {
+                 if (isSubmittable) {
+                     fabManager.showFab();
+                 }
+                 else {
+                     fabManager.hideFab();
+                 }
+             });
+//        viewModel.checkTransactionSubmittable(listAdapter);
+
+        b.fabAdd.setOnClickListener(v -> onFabPressed());
+    }
+    @Override
+    protected void initProfile() {
+        long profileId = getIntent().getLongExtra(PARAM_PROFILE_ID, 0);
+        int profileHue = getIntent().getIntExtra(PARAM_THEME, -1);
+
+        if (profileHue < 0) {
+            Logger.debug(TAG, "Started with invalid/missing theme; quitting");
+            finish();
+            return;
+        }
+
+        if (profileId <= 0) {
+            Logger.debug(TAG, "Started with invalid/missing profile_id; quitting");
+            finish();
+            return;
+        }
+
+        setupProfileColors(profileHue);
+        initProfile(profileId);
+    }
+    @Override
+    public void finish() {
+        super.finish();
+        overridePendingTransition(R.anim.dummy, R.anim.slide_out_down);
+    }
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        if (item.getItemId() == android.R.id.home) {
+            finish();
+            return true;
+        }
+        return super.onOptionsItemSelected(item);
+    }
+    public void onTransactionSave(LedgerTransaction tr) {
+        navController.navigate(R.id.action_newTransactionFragment_to_newTransactionSavingFragment);
+        try {
+
+            SendTransactionTask saver =
+                    new SendTransactionTask(this, mProfile, tr, model.getSimulateSaveFlag());
+            saver.start();
+        }
+        catch (Exception e) {
+            debug("new-transaction", "Unknown error: " + e);
+
+            Bundle b = new Bundle();
+            b.putString("error", "unknown error");
+            navController.navigate(R.id.newTransactionFragment, b);
+        }
+    }
+    public boolean onSimulateCrashMenuItemClicked(MenuItem item) {
+        debug("crash", "Will crash intentionally");
+        GeneralBackgroundTasks.run(() -> { throw new RuntimeException("Simulated crash");});
+        return true;
+    }
+    public boolean onCreateOptionsMenu(Menu menu) {
+        super.onCreateOptionsMenu(menu);
+
+        if (!BuildConfig.DEBUG)
+            return true;
+
+        // Inflate the menu; this adds items to the action bar if it is present.
+        getMenuInflater().inflate(R.menu.new_transaction, menu);
+
+        MenuCompat.setGroupDividerEnabled(menu, true);
+
+        menu.findItem(R.id.action_simulate_save)
+            .setOnMenuItemClickListener(this::onToggleSimulateSaveMenuItemClicked);
+        menu.findItem(R.id.action_simulate_crash)
+            .setOnMenuItemClickListener(this::onSimulateCrashMenuItemClicked);
+
+        model.getSimulateSave()
+             .observe(this, state -> {
+                 menu.findItem(R.id.action_simulate_save)
+                     .setChecked(state);
+                 b.simulationLabel.setVisibility(state ? View.VISIBLE : View.GONE);
+             });
+
+        return true;
+    }
+
+
+    public int dp2px(float dp) {
+        return Math.round(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp,
+                getResources().getDisplayMetrics()));
+    }
+    @Override
+    public void onTransactionSaveDone(String error, Object arg) {
+        Bundle b = new Bundle();
+        if (error != null) {
+            b.putString("error", error);
+            navController.navigate(R.id.action_newTransactionSavingFragment_Failure, b);
+        }
+        else {
+            navController.navigate(R.id.action_newTransactionSavingFragment_Success, b);
+
+            BaseDAO.runAsync(() -> commitToDb((LedgerTransaction) arg));
+        }
+    }
+    public void commitToDb(LedgerTransaction tr) {
+        TransactionWithAccounts dbTransaction = tr.toDBO();
+        DB.get()
+          .getTransactionDAO()
+          .appendSync(dbTransaction);
+    }
+    public boolean onToggleSimulateSaveMenuItemClicked(MenuItem item) {
+        model.toggleSimulateSave();
+        return true;
+    }
+
+    @Override
+    public void triggerQRScan() {
+        qrScanLauncher.launch(null);
+    }
+    private void startNewPatternActivity(String scanned) {
+        Intent intent = new Intent(this, TemplatesActivity.class);
+        Bundle args = new Bundle();
+        args.putString(TemplatesActivity.ARG_ADD_TEMPLATE, scanned);
+        startActivity(intent, args);
+    }
+    private void alertNoTemplateMatch(String scanned) {
+        MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
+        builder.setCancelable(true)
+               .setMessage(R.string.no_template_matches)
+               .setPositiveButton(R.string.add_button,
+                       (dialog, which) -> startNewPatternActivity(scanned))
+               .create()
+               .show();
+    }
+    public void onQRScanResult(String text) {
+        Logger.debug("qr", String.format("Got QR scan result [%s]", text));
+
+        if (Misc.emptyIsNull(text) == null)
+            return;
+
+        LiveData<List<TemplateHeader>> allTemplates = DB.get()
+                                                        .getTemplateDAO()
+                                                        .getTemplates();
+        allTemplates.observe(this, templateHeaders -> {
+            ArrayList<MatchedTemplate> matchingFallbackTemplates = new ArrayList<>();
+            ArrayList<MatchedTemplate> matchingTemplates = new ArrayList<>();
+
+            for (TemplateHeader ph : templateHeaders) {
+                String patternSource = ph.getRegularExpression();
+                if (Misc.emptyIsNull(patternSource) == null)
+                    continue;
+                try {
+                    Pattern pattern = Pattern.compile(patternSource);
+                    Matcher matcher = pattern.matcher(text);
+                    if (!matcher.matches())
+                        continue;
+
+                    Logger.debug("pattern",
+                            String.format("Pattern '%s' [%s] matches '%s'", ph.getName(),
+                                    patternSource, text));
+                    if (ph.isFallback())
+                        matchingFallbackTemplates.add(
+                                new MatchedTemplate(ph, matcher.toMatchResult()));
+                    else
+                        matchingTemplates.add(new MatchedTemplate(ph, matcher.toMatchResult()));
+                }
+                catch (ParcelFormatException e) {
+                    // ignored
+                    Logger.debug("pattern",
+                            String.format("Error compiling regular expression '%s'", patternSource),
+                            e);
+                }
+            }
+
+            if (matchingTemplates.isEmpty())
+                matchingTemplates = matchingFallbackTemplates;
+
+            if (matchingTemplates.isEmpty())
+                alertNoTemplateMatch(text);
+            else if (matchingTemplates.size() == 1)
+                model.applyTemplate(matchingTemplates.get(0), text);
+            else
+                chooseTemplate(matchingTemplates, text);
+        });
+    }
+    private void chooseTemplate(ArrayList<MatchedTemplate> matchingTemplates, String matchedText) {
+        final String templateNameColumn = "name";
+        AbstractCursor cursor = new AbstractCursor() {
+            @Override
+            public int getCount() {
+                return matchingTemplates.size();
+            }
+            @Override
+            public String[] getColumnNames() {
+                return new String[]{"_id", templateNameColumn};
+            }
+            @Override
+            public String getString(int column) {
+                if (column == 0)
+                    return String.valueOf(getPosition());
+                return matchingTemplates.get(getPosition()).templateHead.getName();
+            }
+            @Override
+            public short getShort(int column) {
+                if (column == 0)
+                    return (short) getPosition();
+                return -1;
+            }
+            @Override
+            public int getInt(int column) {
+                return getShort(column);
+            }
+            @Override
+            public long getLong(int column) {
+                return getShort(column);
+            }
+            @Override
+            public float getFloat(int column) {
+                return getShort(column);
+            }
+            @Override
+            public double getDouble(int column) {
+                return getShort(column);
+            }
+            @Override
+            public boolean isNull(int column) {
+                return false;
+            }
+            @Override
+            public int getColumnCount() {
+                return 2;
+            }
+        };
+
+        MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
+        builder.setCancelable(true)
+               .setTitle(R.string.choose_template_to_apply)
+               .setIcon(R.drawable.ic_baseline_auto_graph_24)
+               .setSingleChoiceItems(cursor, -1, templateNameColumn, (dialog, which) -> {
+                   model.applyTemplate(matchingTemplates.get(which), matchedText);
+                   dialog.dismiss();
+               })
+               .create()
+               .show();
+    }
+    public void onDescriptionSelected(String description) {
+        debug("description selected", description);
+        if (!model.accountListIsEmpty())
+            return;
+
+        BaseDAO.runAsync(() -> {
+            String accFilter = mProfile.getPreferredAccountsFilter();
+
+            TransactionDAO trDao = DB.get()
+                                     .getTransactionDAO();
+
+            TransactionWithAccounts tr = null;
+
+            if (Misc.emptyIsNull(accFilter) != null)
+                tr = trDao.getFirstByDescriptionHavingAccountSync(description, accFilter);
+            if (tr == null)
+                tr = trDao.getFirstByDescriptionSync(description);
+
+            if (tr != null)
+                model.loadTransactionIntoModel(tr);
+        });
+    }
+    private void onFabPressed() {
+        fabManager.hideFab();
+        Misc.hideSoftKeyboard(this);
+
+        LedgerTransaction tr = model.constructLedgerTransaction();
+
+        onTransactionSave(tr);
+    }
+    @Override
+    public Context getContext() {
+        return this;
+    }
+    @Override
+    public void showManagedFab() {
+        if (Objects.requireNonNull(model.isSubmittable()
+                                        .getValue()))
+            fabManager.showFab();
+    }
+    @Override
+    public void hideManagedFab() {
+        fabManager.hideFab();
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionFragment.java b/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionFragment.java
new file mode 100644 (file)
index 0000000..ec9fb39
--- /dev/null
@@ -0,0 +1,275 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui.new_transaction;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ProgressBar;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentActivity;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.snackbar.Snackbar;
+
+import net.ktnx.mobileledger.R;
+import net.ktnx.mobileledger.db.Profile;
+import net.ktnx.mobileledger.json.API;
+import net.ktnx.mobileledger.model.Data;
+import net.ktnx.mobileledger.model.LedgerTransaction;
+import net.ktnx.mobileledger.ui.FabManager;
+import net.ktnx.mobileledger.ui.QR;
+import net.ktnx.mobileledger.ui.profiles.ProfileDetailActivity;
+import net.ktnx.mobileledger.utils.Logger;
+
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * A simple {@link Fragment} subclass.
+ * Activities that contain this fragment must implement the
+ * {@link OnNewTransactionFragmentInteractionListener} interface
+ * to handle interaction events.
+ */
+
+// TODO: offer to undo account remove-on-swipe
+
+public class NewTransactionFragment extends Fragment {
+    private NewTransactionItemsAdapter listAdapter;
+    private NewTransactionModel viewModel;
+    private OnNewTransactionFragmentInteractionListener mListener;
+    private Profile mProfile;
+    public NewTransactionFragment() {
+        // Required empty public constructor
+        setHasOptionsMenu(true);
+    }
+    @Override
+    public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
+        super.onCreateOptionsMenu(menu, inflater);
+        final FragmentActivity activity = getActivity();
+
+        inflater.inflate(R.menu.new_transaction_fragment, menu);
+
+        menu.findItem(R.id.scan_qr)
+            .setOnMenuItemClickListener(this::onScanQrAction);
+
+        menu.findItem(R.id.action_reset_new_transaction_activity)
+            .setOnMenuItemClickListener(item -> {
+                viewModel.reset();
+                return true;
+            });
+
+        final MenuItem toggleCurrencyItem = menu.findItem(R.id.toggle_currency);
+        toggleCurrencyItem.setOnMenuItemClickListener(item -> {
+            viewModel.toggleCurrencyVisible();
+            return true;
+        });
+        if (activity != null)
+            viewModel.getShowCurrency()
+                     .observe(activity, toggleCurrencyItem::setChecked);
+
+        final MenuItem toggleCommentsItem = menu.findItem(R.id.toggle_comments);
+        toggleCommentsItem.setOnMenuItemClickListener(item -> {
+            viewModel.toggleShowComments();
+            return true;
+        });
+        if (activity != null)
+            viewModel.getShowComments()
+                     .observe(activity, toggleCommentsItem::setChecked);
+    }
+    private boolean onScanQrAction(MenuItem item) {
+        try {
+            Context ctx = requireContext();
+            if (ctx instanceof QR.QRScanTrigger)
+                ((QR.QRScanTrigger) ctx).triggerQRScan();
+        }
+        catch (Exception e) {
+            Logger.debug("qr", "Error launching QR scanner", e);
+        }
+
+        return true;
+    }
+    @Override
+    public View onCreateView(LayoutInflater inflater, ViewGroup container,
+                             Bundle savedInstanceState) {
+        // Inflate the layout for this fragment
+        return inflater.inflate(R.layout.fragment_new_transaction, container, false);
+    }
+
+    @Override
+    public void onViewCreated(@NotNull View view, @Nullable Bundle savedInstanceState) {
+        super.onViewCreated(view, savedInstanceState);
+        FragmentActivity activity = getActivity();
+        if (activity == null)
+            throw new IllegalStateException(
+                    "getActivity() returned null within onActivityCreated()");
+
+        viewModel = new ViewModelProvider(activity).get(NewTransactionModel.class);
+        viewModel.observeDataProfile(this);
+        mProfile = Data.getProfile();
+        listAdapter = new NewTransactionItemsAdapter(viewModel, mProfile);
+
+        viewModel.getItems()
+                 .observe(getViewLifecycleOwner(), newList -> listAdapter.setItems(newList));
+
+        RecyclerView list = activity.findViewById(R.id.new_transaction_accounts);
+        list.setAdapter(listAdapter);
+        list.setLayoutManager(new LinearLayoutManager(activity));
+
+        Data.observeProfile(getViewLifecycleOwner(), profile -> {
+            mProfile = profile;
+            listAdapter.setProfile(profile);
+        });
+        boolean keep = false;
+
+        Bundle args = getArguments();
+        if (args != null) {
+            String error = args.getString("error");
+            if (error != null) {
+                Logger.debug("new-trans-f", String.format("Got error: %s", error));
+
+                Context context = getContext();
+                if (context != null) {
+                    AlertDialog.Builder builder = new AlertDialog.Builder(context);
+                    final Resources resources = context.getResources();
+                    final StringBuilder message = new StringBuilder();
+                    message.append(resources.getString(R.string.err_json_send_error_head))
+                           .append("\n\n")
+                           .append(error)
+                           .append("\n\n");
+                    if (API.valueOf(mProfile.getApiVersion())
+                           .equals(API.auto))
+                        message.append(
+                                resources.getString(R.string.err_json_send_error_unsupported));
+                    else {
+                        message.append(resources.getString(R.string.err_json_send_error_tail));
+                        builder.setPositiveButton(R.string.btn_profile_options, (dialog, which) -> {
+                            Logger.debug("error", "will start profile editor");
+                            ProfileDetailActivity.start(context, mProfile);
+                        });
+                    }
+                    builder.setMessage(message);
+                    builder.create()
+                           .show();
+                }
+                else {
+                    Snackbar.make(list, error, Snackbar.LENGTH_INDEFINITE)
+                            .show();
+                }
+                keep = true;
+            }
+        }
+
+        int focused = 0;
+        FocusedElement element = null;
+        if (savedInstanceState != null) {
+            keep |= savedInstanceState.getBoolean("keep", true);
+            focused = savedInstanceState.getInt("focused-item", 0);
+            final String focusedElementString = savedInstanceState.getString("focused-element");
+            if (focusedElementString != null)
+                element = FocusedElement.valueOf(focusedElementString);
+        }
+
+        if (!keep) {
+            // we need the DB up and running
+            Data.observeProfile(getViewLifecycleOwner(), p -> {
+                if (p != null)
+                    viewModel.reset();
+            });
+        }
+        else {
+            viewModel.noteFocusChanged(focused, element);
+        }
+
+        ProgressBar p = activity.findViewById(R.id.progressBar);
+        viewModel.getBusyFlag()
+                 .observe(getViewLifecycleOwner(), isBusy -> {
+                     if (isBusy) {
+//                Handler h = new Handler();
+//                h.postDelayed(() -> {
+//                    if (viewModel.getBusyFlag())
+//                        p.setVisibility(View.VISIBLE);
+//
+//                }, 10);
+                         p.setVisibility(View.VISIBLE);
+                     }
+                     else
+                         p.setVisibility(View.INVISIBLE);
+                 });
+
+        if (activity instanceof FabManager.FabHandler)
+            FabManager.handle((FabManager.FabHandler) activity, list);
+    }
+    @Override
+    public void onSaveInstanceState(@NonNull Bundle outState) {
+        super.onSaveInstanceState(outState);
+        outState.putBoolean("keep", true);
+        final NewTransactionModel.FocusInfo focusInfo = viewModel.getFocusInfo()
+                                                                 .getValue();
+        if (focusInfo != null) {
+            final int focusedItem = focusInfo.position;
+            if (focusedItem >= 0)
+                outState.putInt("focused-item", focusedItem);
+            if (focusInfo.element != null)
+                outState.putString("focused-element", focusInfo.element.toString());
+        }
+    }
+
+    @Override
+    public void onAttach(@NotNull Context context) {
+        super.onAttach(context);
+        if (context instanceof OnNewTransactionFragmentInteractionListener) {
+            mListener = (OnNewTransactionFragmentInteractionListener) context;
+        }
+        else {
+            throw new RuntimeException(
+                    context.toString() + " must implement OnFragmentInteractionListener");
+        }
+    }
+
+    @Override
+    public void onDetach() {
+        super.onDetach();
+        mListener = null;
+    }
+
+    /**
+     * This interface must be implemented by activities that contain this
+     * fragment to allow an interaction in this fragment to be communicated
+     * to the activity and potentially other fragments contained in that
+     * activity.
+     * <p>
+     * See the Android Training lesson <a href=
+     * "http://developer.android.com/training/basics/fragments/communicating.html"
+     * >Communicating with Other Fragments</a> for more information.
+     */
+    public interface OnNewTransactionFragmentInteractionListener {
+        void onTransactionSave(LedgerTransaction tr);
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionHeaderItemHolder.java b/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionHeaderItemHolder.java
new file mode 100644 (file)
index 0000000..44464c8
--- /dev/null
@@ -0,0 +1,368 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui.new_transaction;
+
+import android.annotation.SuppressLint;
+import android.graphics.Typeface;
+import android.text.Editable;
+import android.text.TextUtils;
+import android.text.TextWatcher;
+import android.view.View;
+import android.widget.EditText;
+import android.widget.ListAdapter;
+import android.widget.TextView;
+
+import androidx.annotation.ColorInt;
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import net.ktnx.mobileledger.R;
+import net.ktnx.mobileledger.databinding.NewTransactionHeaderRowBinding;
+import net.ktnx.mobileledger.db.TransactionDescriptionAutocompleteAdapter;
+import net.ktnx.mobileledger.model.Data;
+import net.ktnx.mobileledger.model.FutureDates;
+import net.ktnx.mobileledger.ui.DatePickerFragment;
+import net.ktnx.mobileledger.utils.Logger;
+import net.ktnx.mobileledger.utils.Misc;
+import net.ktnx.mobileledger.utils.SimpleDate;
+
+import java.text.DecimalFormatSymbols;
+import java.text.ParseException;
+
+class NewTransactionHeaderItemHolder extends NewTransactionItemViewHolder
+        implements DatePickerFragment.DatePickedListener {
+    private final NewTransactionHeaderRowBinding b;
+    private boolean ignoreFocusChanges = false;
+    private String decimalSeparator;
+    private boolean inUpdate = false;
+    private boolean syncingData = false;
+    NewTransactionHeaderItemHolder(@NonNull NewTransactionHeaderRowBinding b,
+                                   NewTransactionItemsAdapter adapter) {
+        super(b.getRoot());
+        this.b = b;
+
+        b.newTransactionDescription.setNextFocusForwardId(View.NO_ID);
+
+        b.newTransactionDate.setOnClickListener(v -> pickTransactionDate());
+
+        b.transactionCommentButton.setOnClickListener(v -> {
+            b.transactionComment.setVisibility(View.VISIBLE);
+            b.transactionComment.requestFocus();
+        });
+
+        @SuppressLint("DefaultLocale") View.OnFocusChangeListener focusMonitor = (v, hasFocus) -> {
+            final int id = v.getId();
+            if (hasFocus) {
+                boolean wasSyncing = syncingData;
+                syncingData = true;
+                try {
+                    final int pos = getBindingAdapterPosition();
+                    if (id == R.id.transaction_comment) {
+                        adapter.noteFocusIsOnTransactionComment(pos);
+                    }
+                    else if (id == R.id.new_transaction_description) {
+                        adapter.noteFocusIsOnDescription(pos);
+                    }
+                    else
+                        throw new IllegalStateException("Where is the focus? " + id);
+                }
+                finally {
+                    syncingData = wasSyncing;
+                }
+            }
+
+            if (id == R.id.transaction_comment) {
+                commentFocusChanged(b.transactionComment, hasFocus);
+            }
+        };
+
+        b.newTransactionDescription.setOnFocusChangeListener(focusMonitor);
+        b.transactionComment.setOnFocusChangeListener(focusMonitor);
+
+        NewTransactionActivity activity = (NewTransactionActivity) b.getRoot()
+                                                                    .getContext();
+
+        b.newTransactionDescription.setAdapter(
+                new TransactionDescriptionAutocompleteAdapter(activity));
+        b.newTransactionDescription.setOnItemClickListener(
+                (parent, view, position, id) -> activity.onDescriptionSelected(
+                        parent.getItemAtPosition(position)
+                              .toString()));
+
+        decimalSeparator = "";
+        Data.locale.observe(activity, locale -> decimalSeparator = String.valueOf(
+                DecimalFormatSymbols.getInstance(locale)
+                                    .getMonetaryDecimalSeparator()));
+
+        final TextWatcher tw = new TextWatcher() {
+            @Override
+            public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+            }
+
+            @Override
+            public void onTextChanged(CharSequence s, int start, int before, int count) {
+            }
+
+            @Override
+            public void afterTextChanged(Editable s) {
+//                debug("input", "text changed");
+                if (inUpdate)
+                    return;
+
+                Logger.debug("textWatcher", "calling syncData()");
+                if (syncData()) {
+                    Logger.debug("textWatcher",
+                            "syncData() returned, checking if transaction is submittable");
+                    adapter.model.checkTransactionSubmittable(null);
+                }
+                Logger.debug("textWatcher", "done");
+            }
+        };
+        b.newTransactionDescription.addTextChangedListener(tw);
+        monitorComment(b.transactionComment);
+
+        commentFocusChanged(b.transactionComment, false);
+
+        adapter.model.getFocusInfo()
+                     .observe(activity, this::applyFocus);
+
+        adapter.model.getShowComments()
+                     .observe(activity, show -> b.transactionCommentLayout.setVisibility(
+                             show ? View.VISIBLE : View.GONE));
+    }
+    private void applyFocus(NewTransactionModel.FocusInfo focusInfo) {
+        if (ignoreFocusChanges) {
+            Logger.debug("new-trans", "Ignoring focus change");
+            return;
+        }
+        ignoreFocusChanges = true;
+        try {
+            if (((focusInfo == null) || (focusInfo.element == null) ||
+                 focusInfo.position != getBindingAdapterPosition()))
+                return;
+
+            final NewTransactionModel.Item item = getItem();
+            if (item == null)
+                return;
+
+            NewTransactionModel.Item head = item.toTransactionHead();
+            // bad idea - double pop-up, and not really necessary.
+            // the user can tap the input to get the calendar
+            //if (!tvDate.hasFocus()) tvDate.requestFocus();
+            switch (focusInfo.element) {
+                case TransactionComment:
+                    b.transactionComment.setVisibility(View.VISIBLE);
+                    b.transactionComment.requestFocus();
+                    break;
+                case Description:
+                    boolean focused = b.newTransactionDescription.requestFocus();
+//                            tvDescription.dismissDropDown();
+                    if (focused)
+                        Misc.showSoftKeyboard((NewTransactionActivity) b.getRoot()
+                                                                        .getContext());
+                    break;
+            }
+        }
+        finally {
+            ignoreFocusChanges = false;
+        }
+    }
+    private void monitorComment(EditText editText) {
+        editText.addTextChangedListener(new TextWatcher() {
+            @Override
+            public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+            }
+            @Override
+            public void onTextChanged(CharSequence s, int start, int before, int count) {
+            }
+            @Override
+            public void afterTextChanged(Editable s) {
+//                debug("input", "text changed");
+                if (inUpdate)
+                    return;
+
+                Logger.debug("textWatcher", "calling syncData()");
+                syncData();
+                Logger.debug("textWatcher",
+                        "syncData() returned, checking if transaction is submittable");
+                styleComment(editText, s.toString());
+                Logger.debug("textWatcher", "done");
+            }
+        });
+    }
+    private void commentFocusChanged(TextView textView, boolean hasFocus) {
+        @ColorInt int textColor;
+        textColor = b.dummyText.getTextColors()
+                               .getDefaultColor();
+        if (hasFocus) {
+            textView.setTypeface(null, Typeface.NORMAL);
+            textView.setHint(R.string.transaction_account_comment_hint);
+        }
+        else {
+            int alpha = (textColor >> 24 & 0xff);
+            alpha = 3 * alpha / 4;
+            textColor = (alpha << 24) | (0x00ffffff & textColor);
+            textView.setTypeface(null, Typeface.ITALIC);
+            textView.setHint("");
+            if (TextUtils.isEmpty(textView.getText())) {
+                textView.setVisibility(View.INVISIBLE);
+            }
+        }
+        textView.setTextColor(textColor);
+
+    }
+    private void setEditable(Boolean editable) {
+        b.newTransactionDate.setEnabled(editable);
+        b.newTransactionDescription.setEnabled(editable);
+    }
+    private void beginUpdates() {
+        if (inUpdate)
+            throw new RuntimeException("Already in update mode");
+        inUpdate = true;
+    }
+    private void endUpdates() {
+        if (!inUpdate)
+            throw new RuntimeException("Not in update mode");
+        inUpdate = false;
+    }
+    /**
+     * syncData()
+     * <p>
+     * Stores the data from the UI elements into the model item
+     * Returns true if there were changes made that suggest transaction has to be
+     * checked for being submittable
+     */
+    private boolean syncData() {
+        if (syncingData) {
+            Logger.debug("new-trans", "skipping syncData() loop");
+            return false;
+        }
+
+        if (getBindingAdapterPosition() == RecyclerView.NO_POSITION) {
+            // probably the row was swiped out
+            Logger.debug("new-trans", "Ignoring request to syncData(): adapter position negative");
+            return false;
+        }
+
+
+        boolean significantChange = false;
+
+        syncingData = true;
+        try {
+            final NewTransactionModel.Item item = getItem();
+            if (item == null)
+                return false;
+
+            NewTransactionModel.TransactionHead head = item.toTransactionHead();
+
+            head.setDate(String.valueOf(b.newTransactionDate.getText()));
+
+            // transaction description is required
+            if (TextUtils.isEmpty(head.getDescription()) !=
+                TextUtils.isEmpty(b.newTransactionDescription.getText()))
+                significantChange = true;
+
+            head.setDescription(String.valueOf(b.newTransactionDescription.getText()));
+            head.setComment(String.valueOf(b.transactionComment.getText()));
+
+            return significantChange;
+        }
+        catch (ParseException e) {
+            throw new RuntimeException("Should not happen", e);
+        }
+        finally {
+            syncingData = false;
+        }
+    }
+    private void pickTransactionDate() {
+        DatePickerFragment picker = new DatePickerFragment();
+        picker.setFutureDates(FutureDates.valueOf(mProfile.getFutureDates()));
+        picker.setOnDatePickedListener(this);
+        picker.setCurrentDateFromText(b.newTransactionDate.getText());
+        picker.show(((NewTransactionActivity) b.getRoot()
+                                               .getContext()).getSupportFragmentManager(), null);
+    }
+    /**
+     * bind
+     *
+     * @param item updates the UI elements with the data from the model item
+     */
+    @SuppressLint("DefaultLocale")
+    public void bind(@NonNull NewTransactionModel.Item item) {
+        beginUpdates();
+        try {
+            syncingData = true;
+            try {
+                NewTransactionModel.TransactionHead head = item.toTransactionHead();
+                b.newTransactionDate.setText(head.getFormattedDate());
+
+                // avoid triggering completion pop-up
+                ListAdapter a = b.newTransactionDescription.getAdapter();
+                try {
+                    b.newTransactionDescription.setAdapter(null);
+                    b.newTransactionDescription.setText(head.getDescription());
+                }
+                finally {
+                    b.newTransactionDescription.setAdapter(
+                            (TransactionDescriptionAutocompleteAdapter) a);
+                }
+
+                final String comment = head.getComment();
+                b.transactionComment.setText(comment);
+                styleComment(b.transactionComment, comment); // would hide or make it visible
+
+                setEditable(true);
+
+                NewTransactionItemsAdapter adapter =
+                        (NewTransactionItemsAdapter) getBindingAdapter();
+                if (adapter != null)
+                    applyFocus(adapter.model.getFocusInfo()
+                                            .getValue());
+            }
+            finally {
+                syncingData = false;
+            }
+        }
+        finally {
+            endUpdates();
+        }
+    }
+    private void styleComment(EditText editText, String comment) {
+        final View focusedView = editText.findFocus();
+        editText.setTypeface(null, (focusedView == editText) ? Typeface.NORMAL : Typeface.ITALIC);
+        editText.setVisibility(
+                ((focusedView != editText) && TextUtils.isEmpty(comment)) ? View.INVISIBLE
+                                                                          : View.VISIBLE);
+    }
+    @Override
+    public void onDatePicked(int year, int month, int day) {
+        final NewTransactionModel.Item item = getItem();
+        if (item == null)
+            return;
+
+        final NewTransactionModel.TransactionHead head = item.toTransactionHead();
+        head.setDate(new SimpleDate(year, month + 1, day));
+        b.newTransactionDate.setText(head.getFormattedDate());
+
+        boolean focused = b.newTransactionDescription.requestFocus();
+        if (focused)
+            Misc.showSoftKeyboard((NewTransactionActivity) b.getRoot()
+                                                            .getContext());
+
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionItemViewHolder.java b/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionItemViewHolder.java
new file mode 100644 (file)
index 0000000..25e0bb6
--- /dev/null
@@ -0,0 +1,43 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui.new_transaction;
+
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.recyclerview.widget.RecyclerView;
+
+import net.ktnx.mobileledger.db.Profile;
+import net.ktnx.mobileledger.model.Data;
+
+abstract class NewTransactionItemViewHolder extends RecyclerView.ViewHolder {
+    final Profile mProfile;
+    public NewTransactionItemViewHolder(@NonNull View itemView) {
+        super(itemView);
+        mProfile = Data.getProfile();
+    }
+    @Nullable
+    NewTransactionModel.Item getItem() {
+        NewTransactionItemsAdapter adapter = (NewTransactionItemsAdapter) getBindingAdapter();
+        if (adapter == null)
+            return null;
+        return adapter.getItem(getBindingAdapterPosition());
+    }
+    abstract public void bind(NewTransactionModel.Item item);
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionItemsAdapter.java b/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionItemsAdapter.java
new file mode 100644 (file)
index 0000000..b6b17be
--- /dev/null
@@ -0,0 +1,235 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui.new_transaction;
+
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.AsyncListDiffer;
+import androidx.recyclerview.widget.DiffUtil;
+import androidx.recyclerview.widget.ItemTouchHelper;
+import androidx.recyclerview.widget.RecyclerView;
+
+import net.ktnx.mobileledger.databinding.NewTransactionAccountRowBinding;
+import net.ktnx.mobileledger.databinding.NewTransactionHeaderRowBinding;
+import net.ktnx.mobileledger.db.Profile;
+import net.ktnx.mobileledger.utils.Logger;
+
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+
+class NewTransactionItemsAdapter extends RecyclerView.Adapter<NewTransactionItemViewHolder> {
+    private static final int ITEM_VIEW_TYPE_HEADER = 1;
+    private static final int ITEM_VIEW_TYPE_ACCOUNT = 2;
+    final NewTransactionModel model;
+    private final ItemTouchHelper touchHelper;
+    private final AsyncListDiffer<NewTransactionModel.Item> differ =
+            new AsyncListDiffer<>(this, new DiffUtil.ItemCallback<NewTransactionModel.Item>() {
+                @Override
+                public boolean areItemsTheSame(@NonNull NewTransactionModel.Item oldItem,
+                                               @NonNull NewTransactionModel.Item newItem) {
+//                    Logger.debug("new-trans",
+//                            String.format("comparing ids of {%s} and {%s}", oldItem.toString(),
+//                                    newItem.toString()));
+                    return oldItem.getId() == newItem.getId();
+                }
+                @Override
+                public boolean areContentsTheSame(@NonNull NewTransactionModel.Item oldItem,
+                                                  @NonNull NewTransactionModel.Item newItem) {
+
+//                    Logger.debug("new-trans",
+//                            String.format("comparing contents of {%s} and {%s}", oldItem
+//                            .toString(),
+//                                    newItem.toString()));
+                    return oldItem.equalContents(newItem);
+                }
+            });
+    private Profile mProfile;
+    private int checkHoldCounter = 0;
+    NewTransactionItemsAdapter(NewTransactionModel viewModel, Profile profile) {
+        super();
+        setHasStableIds(true);
+        model = viewModel;
+        mProfile = profile;
+
+
+        NewTransactionItemsAdapter adapter = this;
+
+        touchHelper = new ItemTouchHelper(new ItemTouchHelper.Callback() {
+            @Override
+            public boolean isLongPressDragEnabled() {
+                return true;
+            }
+            @Override
+            public boolean canDropOver(@NonNull RecyclerView recyclerView,
+                                       @NonNull RecyclerView.ViewHolder current,
+                                       @NonNull RecyclerView.ViewHolder target) {
+                final int adapterPosition = target.getBindingAdapterPosition();
+
+                // first item is immovable
+                if (adapterPosition == 0)
+                    return false;
+
+                return super.canDropOver(recyclerView, current, target);
+            }
+            @Override
+            public int getMovementFlags(@NonNull RecyclerView recyclerView,
+                                        @NonNull RecyclerView.ViewHolder viewHolder) {
+                int flags = makeFlag(ItemTouchHelper.ACTION_STATE_IDLE, ItemTouchHelper.END);
+                // the top (date and description) and the bottom (padding) items are always there
+                final int adapterPosition = viewHolder.getBindingAdapterPosition();
+                if (adapterPosition > 0) {
+                    flags |= makeFlag(ItemTouchHelper.ACTION_STATE_DRAG,
+                            ItemTouchHelper.UP | ItemTouchHelper.DOWN) |
+                             makeFlag(ItemTouchHelper.ACTION_STATE_SWIPE,
+                                     ItemTouchHelper.START | ItemTouchHelper.END);
+                }
+
+                return flags;
+            }
+            @Override
+            public boolean onMove(@NonNull RecyclerView recyclerView,
+                                  @NonNull RecyclerView.ViewHolder viewHolder,
+                                  @NonNull RecyclerView.ViewHolder target) {
+
+                model.moveItem(viewHolder.getBindingAdapterPosition(),
+                        target.getBindingAdapterPosition());
+                return true;
+            }
+            @Override
+            public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) {
+                int pos = viewHolder.getBindingAdapterPosition();
+                viewModel.removeItem(pos);
+            }
+        });
+    }
+    @Override
+    public int getItemViewType(int position) {
+        final ItemType type = differ.getCurrentList()
+                                    .get(position)
+                                    .getType();
+        switch (type) {
+            case generalData:
+                return ITEM_VIEW_TYPE_HEADER;
+            case transactionRow:
+                return ITEM_VIEW_TYPE_ACCOUNT;
+            default:
+                throw new RuntimeException("Can't handle " + type);
+        }
+    }
+    @Override
+    public long getItemId(int position) {
+        return differ.getCurrentList()
+                     .get(position)
+                     .getId();
+    }
+    public void setProfile(Profile profile) {
+        mProfile = profile;
+    }
+    @NonNull
+    @Override
+    public NewTransactionItemViewHolder onCreateViewHolder(@NonNull ViewGroup parent,
+                                                           int viewType) {
+        switch (viewType) {
+            case ITEM_VIEW_TYPE_HEADER:
+                NewTransactionHeaderRowBinding headerBinding =
+                        NewTransactionHeaderRowBinding.inflate(
+                                LayoutInflater.from(parent.getContext()), parent, false);
+                final NewTransactionHeaderItemHolder headerHolder =
+                        new NewTransactionHeaderItemHolder(headerBinding, this);
+                Logger.debug("new-trans", "Creating new Header ViewHolder " +
+                                          Integer.toHexString(headerHolder.hashCode()));
+                return headerHolder;
+            case ITEM_VIEW_TYPE_ACCOUNT:
+                NewTransactionAccountRowBinding accBinding =
+                        NewTransactionAccountRowBinding.inflate(
+                                LayoutInflater.from(parent.getContext()), parent, false);
+                final NewTransactionAccountRowItemHolder accHolder =
+                        new NewTransactionAccountRowItemHolder(accBinding, this);
+                Logger.debug("new-trans", "Creating new AccountRow ViewHolder " +
+                                          Integer.toHexString(accHolder.hashCode()));
+                return accHolder;
+            default:
+                throw new RuntimeException("Cant handle view type " + viewType);
+        }
+    }
+    @Override
+    public void onBindViewHolder(@NonNull NewTransactionItemViewHolder holder, int position) {
+        Logger.debug("bind",
+                String.format(Locale.US, "Binding item at position %d, holder %s", position,
+                        Integer.toHexString(holder.hashCode())));
+        NewTransactionModel.Item item = Objects.requireNonNull(differ.getCurrentList()
+                                                                     .get(position));
+        holder.bind(item);
+        Logger.debug("bind", String.format(Locale.US, "Bound %s item at position %d", item.getType()
+                                                                                          .toString(),
+                position));
+    }
+    @Override
+    public int getItemCount() {
+        return differ.getCurrentList()
+                     .size();
+    }
+    @Override
+    public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
+        super.onAttachedToRecyclerView(recyclerView);
+        touchHelper.attachToRecyclerView(recyclerView);
+    }
+    @Override
+    public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) {
+        touchHelper.attachToRecyclerView(null);
+        super.onDetachedFromRecyclerView(recyclerView);
+    }
+    void noteFocusIsOnAccount(int position) {
+        model.noteFocusChanged(position, FocusedElement.Account);
+    }
+    void noteFocusIsOnAmount(int position) {
+        model.noteFocusChanged(position, FocusedElement.Amount);
+    }
+    void noteFocusIsOnComment(int position) {
+        model.noteFocusChanged(position, FocusedElement.Comment);
+    }
+    void noteFocusIsOnTransactionComment(int position) {
+        model.noteFocusChanged(position, FocusedElement.TransactionComment);
+    }
+    public void noteFocusIsOnDescription(int pos) {
+        model.noteFocusChanged(pos, FocusedElement.Description);
+    }
+    private void holdSubmittableChecks() {
+        checkHoldCounter++;
+    }
+    private void releaseSubmittableChecks() {
+        if (checkHoldCounter == 0)
+            throw new RuntimeException("Asymmetrical call to releaseSubmittableChecks");
+        checkHoldCounter--;
+    }
+    void setItemCurrency(int position, String newCurrency) {
+        model.setItemCurrency(position, newCurrency);
+    }
+
+    public void setItems(List<NewTransactionModel.Item> newList) {
+        Logger.debug("new-trans", "adapter: submitting new item list");
+        differ.submitList(newList);
+    }
+    public NewTransactionModel.Item getItem(int position) {
+        return differ.getCurrentList()
+                     .get(position);
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionModel.java b/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionModel.java
new file mode 100644 (file)
index 0000000..51bcd30
--- /dev/null
@@ -0,0 +1,1366 @@
+/*
+ * Copyright © 2022 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui.new_transaction;
+
+import android.annotation.SuppressLint;
+import android.os.Build;
+import android.text.TextUtils;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
+import androidx.lifecycle.Observer;
+import androidx.lifecycle.ViewModel;
+
+import net.ktnx.mobileledger.BuildConfig;
+import net.ktnx.mobileledger.db.Currency;
+import net.ktnx.mobileledger.db.DB;
+import net.ktnx.mobileledger.db.Profile;
+import net.ktnx.mobileledger.db.TemplateAccount;
+import net.ktnx.mobileledger.db.TemplateHeader;
+import net.ktnx.mobileledger.db.TransactionWithAccounts;
+import net.ktnx.mobileledger.model.Data;
+import net.ktnx.mobileledger.model.InertMutableLiveData;
+import net.ktnx.mobileledger.model.LedgerTransaction;
+import net.ktnx.mobileledger.model.LedgerTransactionAccount;
+import net.ktnx.mobileledger.model.MatchedTemplate;
+import net.ktnx.mobileledger.utils.Globals;
+import net.ktnx.mobileledger.utils.Logger;
+import net.ktnx.mobileledger.utils.Misc;
+import net.ktnx.mobileledger.utils.SimpleDate;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.text.ParseException;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.GregorianCalendar;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.regex.MatchResult;
+
+enum ItemType {generalData, transactionRow}
+
+enum FocusedElement {Account, Comment, Amount, Description, TransactionComment}
+
+
+public class NewTransactionModel extends ViewModel {
+    private static final int MIN_ITEMS = 3;
+    private final MutableLiveData<Boolean> showCurrency = new MutableLiveData<>(false);
+    private final MutableLiveData<Boolean> isSubmittable = new InertMutableLiveData<>(false);
+    private final MutableLiveData<Boolean> showComments = new MutableLiveData<>(true);
+    private final MutableLiveData<List<Item>> items = new MutableLiveData<>();
+    private final MutableLiveData<Boolean> simulateSave = new InertMutableLiveData<>(false);
+    private final AtomicInteger busyCounter = new AtomicInteger(0);
+    private final MutableLiveData<Boolean> busyFlag = new InertMutableLiveData<>(false);
+    private final Observer<Profile> profileObserver = profile -> {
+        if (profile != null) {
+            showCurrency.postValue(profile.getShowCommodityByDefault());
+            showComments.postValue(profile.getShowCommentsByDefault());
+        }
+    };
+    private final MutableLiveData<FocusInfo> focusInfo = new MutableLiveData<>();
+    private boolean observingDataProfile;
+    public NewTransactionModel() {
+    }
+    public LiveData<Boolean> getShowCurrency() {
+        return showCurrency;
+    }
+    public LiveData<List<Item>> getItems() {
+        return items;
+    }
+    private void setItems(@NonNull List<Item> newList) {
+        checkTransactionSubmittable(newList);
+        setItemsWithoutSubmittableChecks(newList);
+    }
+    private void replaceItems(@NonNull List<Item> newList) {
+        renumberItems();
+
+        setItems(newList);
+    }
+    /**
+     * make old items replaceable in-place. makes the new values visually blend in
+     */
+    private void renumberItems() {
+        renumberItems(items.getValue());
+    }
+    private void renumberItems(List<Item> list) {
+        if (list == null) {
+            return;
+        }
+
+        int id = 0;
+        for (Item item : list)
+            item.id = id++;
+    }
+    private void setItemsWithoutSubmittableChecks(@NonNull List<Item> list) {
+        final int cnt = list.size();
+        for (int i = 1; i < cnt - 1; i++) {
+            final TransactionAccount item = list.get(i)
+                                                .toTransactionAccount();
+            if (item.isLast) {
+                TransactionAccount replacement = new TransactionAccount(item);
+                replacement.isLast = false;
+                list.set(i, replacement);
+            }
+        }
+        final TransactionAccount last = list.get(cnt - 1)
+                                            .toTransactionAccount();
+        if (!last.isLast) {
+            TransactionAccount replacement = new TransactionAccount(last);
+            replacement.isLast = true;
+            list.set(cnt - 1, replacement);
+        }
+
+        if (BuildConfig.DEBUG)
+            dumpItemList("Before setValue()", list);
+        items.setValue(list);
+    }
+    private List<Item> copyList() {
+        List<Item> copy = new ArrayList<>();
+        List<Item> oldList = items.getValue();
+
+        if (oldList != null)
+            for (Item item : oldList) {
+                copy.add(Item.from(item));
+            }
+
+        return copy;
+    }
+    private List<Item> copyListWithoutItem(int position) {
+        List<Item> copy = new ArrayList<>();
+        List<Item> oldList = items.getValue();
+
+        if (oldList != null) {
+            int i = 0;
+            for (Item item : oldList) {
+                if (i++ == position)
+                    continue;
+                copy.add(Item.from(item));
+            }
+        }
+
+        return copy;
+    }
+    private List<Item> shallowCopyList() {
+        return new ArrayList<>(Objects.requireNonNull(items.getValue()));
+    }
+    LiveData<Boolean> getShowComments() {
+        return showComments;
+    }
+    void observeDataProfile(LifecycleOwner activity) {
+        if (!observingDataProfile)
+            Data.observeProfile(activity, profileObserver);
+        observingDataProfile = true;
+    }
+    boolean getSimulateSaveFlag() {
+        Boolean value = simulateSave.getValue();
+        if (value == null)
+            return false;
+        return value;
+    }
+    LiveData<Boolean> getSimulateSave() {
+        return simulateSave;
+    }
+    void toggleSimulateSave() {
+        simulateSave.setValue(!getSimulateSaveFlag());
+    }
+    LiveData<Boolean> isSubmittable() {
+        return this.isSubmittable;
+    }
+    void reset() {
+        Logger.debug("new-trans", "Resetting model");
+        List<Item> list = new ArrayList<>();
+        Item.resetIdDispenser();
+        list.add(new TransactionHead(""));
+        final String defaultCurrency = Objects.requireNonNull(Data.getProfile())
+                                              .getDefaultCommodity();
+        list.add(new TransactionAccount("", defaultCurrency));
+        list.add(new TransactionAccount("", defaultCurrency));
+        noteFocusChanged(0, FocusedElement.Description);
+        renumberItems();
+        isSubmittable.setValue(false);
+        setItemsWithoutSubmittableChecks(list);
+    }
+    boolean accountsInInitialState() {
+        final List<Item> list = items.getValue();
+
+        if (list == null)
+            return true;
+
+        for (Item item : list) {
+            if (!(item instanceof TransactionAccount))
+                continue;
+
+            TransactionAccount accRow = (TransactionAccount) item;
+            if (!accRow.isEmpty())
+                return false;
+        }
+
+        return true;
+    }
+    void applyTemplate(MatchedTemplate matchedTemplate, String text) {
+        SimpleDate transactionDate = null;
+        final MatchResult matchResult = matchedTemplate.matchResult;
+        final TemplateHeader templateHead = matchedTemplate.templateHead;
+        {
+            int day = extractIntFromMatches(matchResult, templateHead.getDateDayMatchGroup(),
+                    templateHead.getDateDay());
+            int month = extractIntFromMatches(matchResult, templateHead.getDateMonthMatchGroup(),
+                    templateHead.getDateMonth());
+            int year = extractIntFromMatches(matchResult, templateHead.getDateYearMatchGroup(),
+                    templateHead.getDateYear());
+
+            if (year > 0 || month > 0 || day > 0) {
+                SimpleDate today = SimpleDate.today();
+                if (year <= 0)
+                    year = today.year;
+                if (month <= 0)
+                    month = today.month;
+                if (day <= 0)
+                    day = today.day;
+
+                transactionDate = new SimpleDate(year, month, day);
+
+                Logger.debug("pattern", "setting transaction date to " + transactionDate);
+            }
+        }
+
+        List<Item> present = copyList();
+
+        TransactionHead head = new TransactionHead(present.get(0)
+                                                          .toTransactionHead());
+        if (transactionDate != null)
+            head.setDate(transactionDate);
+
+        final String transactionDescription = extractStringFromMatches(matchResult,
+                templateHead.getTransactionDescriptionMatchGroup(),
+                templateHead.getTransactionDescription());
+        if (Misc.emptyIsNull(transactionDescription) != null)
+            head.setDescription(transactionDescription);
+
+        final String transactionComment = extractStringFromMatches(matchResult,
+                templateHead.getTransactionCommentMatchGroup(),
+                templateHead.getTransactionComment());
+        if (Misc.emptyIsNull(transactionComment) != null)
+            head.setComment(transactionComment);
+
+        List<Item> newItems = new ArrayList<>();
+
+        newItems.add(head);
+
+        for (int i = 1; i < present.size(); i++) {
+            final TransactionAccount row = present.get(i)
+                                                  .toTransactionAccount();
+            if (!row.isEmpty())
+                newItems.add(new TransactionAccount(row));
+        }
+
+        DB.get()
+          .getTemplateDAO()
+          .getTemplateWithAccountsAsync(templateHead.getId(), entry -> {
+              int rowIndex = 0;
+              final boolean accountsInInitialState = accountsInInitialState();
+              for (TemplateAccount acc : entry.accounts) {
+                  rowIndex++;
+
+                  String accountName =
+                          extractStringFromMatches(matchResult, acc.getAccountNameMatchGroup(),
+                                  acc.getAccountName());
+                  String accountComment =
+                          extractStringFromMatches(matchResult, acc.getAccountCommentMatchGroup(),
+                                  acc.getAccountComment());
+                  Float amount = extractFloatFromMatches(matchResult, acc.getAmountMatchGroup(),
+                          acc.getAmount());
+                  if (amount != null && acc.getNegateAmount() != null && acc.getNegateAmount())
+                      amount = -amount;
+
+                  TransactionAccount accRow = new TransactionAccount(accountName);
+                  accRow.setComment(accountComment);
+                  if (amount != null)
+                      accRow.setAmount(amount);
+                  accRow.setCurrency(
+                          extractCurrencyFromMatches(matchResult, acc.getCurrencyMatchGroup(),
+                                  acc.getCurrencyObject()));
+
+                  newItems.add(accRow);
+              }
+
+              renumberItems(newItems);
+              Misc.onMainThread(() -> replaceItems(newItems));
+          });
+    }
+    @NonNull
+    private String extractCurrencyFromMatches(MatchResult m, Integer group, Currency literal) {
+        return Misc.nullIsEmpty(
+                extractStringFromMatches(m, group, (literal == null) ? "" : literal.getName()));
+    }
+    private int extractIntFromMatches(MatchResult m, Integer group, Integer literal) {
+        if (literal != null)
+            return literal;
+
+        if (group != null) {
+            int grp = group;
+            if (grp > 0 && grp <= m.groupCount())
+                try {
+                    return Integer.parseInt(m.group(grp));
+                }
+                catch (NumberFormatException e) {
+                    Logger.debug("new-trans", "Error extracting matched number", e);
+                }
+        }
+
+        return 0;
+    }
+    @Nullable
+    private String extractStringFromMatches(MatchResult m, Integer group, String literal) {
+        if (literal != null)
+            return literal;
+
+        if (group != null) {
+            int grp = group;
+            if (grp > 0 && grp <= m.groupCount())
+                return m.group(grp);
+        }
+
+        return null;
+    }
+    private Float extractFloatFromMatches(MatchResult m, Integer group, Float literal) {
+        if (literal != null)
+            return literal;
+
+        if (group != null) {
+            int grp = group;
+            if (grp > 0 && grp <= m.groupCount())
+                try {
+                    return Float.valueOf(m.group(grp));
+                }
+                catch (NumberFormatException e) {
+                    Logger.debug("new-trans", "Error extracting matched number", e);
+                }
+        }
+
+        return null;
+    }
+    void removeItem(int pos) {
+        Logger.debug("new-trans", String.format(Locale.US, "Removing item at position %d", pos));
+        List<Item> newList = copyListWithoutItem(pos);
+        final FocusInfo fi = focusInfo.getValue();
+        if ((fi != null) && (pos < fi.position))
+            noteFocusChanged(fi.position - 1, fi.element);
+        setItems(newList);
+    }
+    void noteFocusChanged(int position, @Nullable FocusedElement element) {
+        FocusInfo present = focusInfo.getValue();
+        if (present == null || present.position != position || present.element != element)
+            focusInfo.setValue(new FocusInfo(position, element));
+    }
+    public LiveData<FocusInfo> getFocusInfo() {
+        return focusInfo;
+    }
+    void moveItem(int fromIndex, int toIndex) {
+        List<Item> newList = shallowCopyList();
+        Item item = newList.remove(fromIndex);
+        newList.add(toIndex, item);
+
+        FocusInfo fi = focusInfo.getValue();
+        if (fi != null && fi.position == fromIndex)
+            noteFocusChanged(toIndex, fi.element);
+
+        items.setValue(newList); // same count, same submittable state
+    }
+    void moveItemLast(List<Item> list, int index) {
+        /*   0
+             1   <-- index
+             2
+             3   <-- desired position
+                 (no bottom filler)
+         */
+        int itemCount = list.size();
+
+        if (index < itemCount - 1)
+            list.add(list.remove(index));
+    }
+    void toggleCurrencyVisible() {
+        final boolean newValue = !Objects.requireNonNull(showCurrency.getValue());
+
+        // remove currency from all items, or reset currency to the default
+        // no need to clone the list, because the removal of the currency won't lead to
+        // visual changes -- the currency fields will be hidden or reset to default anyway
+        // still, there may be changes in the submittable state
+        final List<Item> list = Objects.requireNonNull(this.items.getValue());
+        final Profile profile = Objects.requireNonNull(Data.getProfile());
+        for (int i = 1; i < list.size(); i++) {
+            ((TransactionAccount) list.get(i)).setCurrency(
+                    newValue ? profile.getDefaultCommodity() : "");
+        }
+        checkTransactionSubmittable(null);
+        showCurrency.setValue(newValue);
+    }
+    void stopObservingBusyFlag(Observer<Boolean> observer) {
+        busyFlag.removeObserver(observer);
+    }
+    void incrementBusyCounter() {
+        int newValue = busyCounter.incrementAndGet();
+        if (newValue == 1)
+            busyFlag.postValue(true);
+    }
+    void decrementBusyCounter() {
+        int newValue = busyCounter.decrementAndGet();
+        if (newValue == 0)
+            busyFlag.postValue(false);
+    }
+    public LiveData<Boolean> getBusyFlag() {
+        return busyFlag;
+    }
+    public void toggleShowComments() {
+        showComments.setValue(!Objects.requireNonNull(showComments.getValue()));
+    }
+    public LedgerTransaction constructLedgerTransaction() {
+        List<Item> list = Objects.requireNonNull(items.getValue());
+        TransactionHead head = list.get(0)
+                                   .toTransactionHead();
+        LedgerTransaction tr = head.asLedgerTransaction();
+
+        tr.setComment(head.getComment());
+        HashMap<String, List<LedgerTransactionAccount>> emptyAmountAccounts = new HashMap<>();
+        HashMap<String, Float> emptyAmountAccountBalance = new HashMap<>();
+        for (int i = 1; i < list.size(); i++) {
+            TransactionAccount item = list.get(i)
+                                          .toTransactionAccount();
+            String currency = item.getCurrency();
+            LedgerTransactionAccount acc = new LedgerTransactionAccount(item.getAccountName()
+                                                                            .trim(), currency);
+            if (acc.getAccountName()
+                   .isEmpty())
+                continue;
+
+            acc.setComment(item.getComment());
+
+            if (item.isAmountSet()) {
+                acc.setAmount(item.getAmount());
+                Float emptyCurrBalance = emptyAmountAccountBalance.get(currency);
+                if (emptyCurrBalance == null) {
+                    emptyAmountAccountBalance.put(currency, item.getAmount());
+                }
+                else {
+                    emptyAmountAccountBalance.put(currency, emptyCurrBalance + item.getAmount());
+                }
+            }
+            else {
+                List<LedgerTransactionAccount> emptyCurrAccounts =
+                        emptyAmountAccounts.get(currency);
+                if (emptyCurrAccounts == null)
+                    emptyAmountAccounts.put(currency, emptyCurrAccounts = new ArrayList<>());
+                emptyCurrAccounts.add(acc);
+            }
+
+            tr.addAccount(acc);
+        }
+
+        if (emptyAmountAccounts.size() > 0) {
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+                emptyAmountAccounts.forEach((currency, accounts) -> {
+                    final Float balance = emptyAmountAccountBalance.get(currency);
+
+                    if (balance != null && !Misc.isZero(balance) && accounts.size() != 1) {
+                        throw new RuntimeException(String.format(Locale.US,
+                                "Should not happen: approved transaction has %d accounts " +
+                                "without amounts for currency '%s'", accounts.size(), currency));
+                    }
+                    accounts.forEach(acc -> acc.setAmount(balance == null ? 0 : -balance));
+                });
+            }
+            else {
+                for (String currency : emptyAmountAccounts.keySet()) {
+                    List<LedgerTransactionAccount> accounts =
+                            Objects.requireNonNull(emptyAmountAccounts.get(currency));
+                    final Float balance = emptyAmountAccountBalance.get(currency);
+                    if (balance != null && !Misc.isZero(balance) && accounts.size() != 1)
+                        throw new RuntimeException(String.format(Locale.US,
+                                "Should not happen: approved transaction has %d accounts for " +
+                                "currency %s", accounts.size(), currency));
+                    for (LedgerTransactionAccount acc : accounts) {
+                        acc.setAmount(balance == null ? 0 : -balance);
+                    }
+                }
+            }
+        }
+
+        return tr;
+    }
+    void loadTransactionIntoModel(@NonNull TransactionWithAccounts tr) {
+        List<Item> newList = new ArrayList<>();
+        Item.resetIdDispenser();
+
+        Item currentHead = Objects.requireNonNull(items.getValue())
+                                  .get(0);
+        TransactionHead head = new TransactionHead(tr.transaction.getDescription());
+        head.setComment(tr.transaction.getComment());
+        if (currentHead instanceof TransactionHead)
+            head.setDate(((TransactionHead) currentHead).date);
+
+        newList.add(head);
+
+        List<LedgerTransactionAccount> accounts = new ArrayList<>();
+        for (net.ktnx.mobileledger.db.TransactionAccount acc : tr.accounts) {
+            accounts.add(new LedgerTransactionAccount(acc));
+        }
+
+        TransactionAccount firstNegative = null;
+        TransactionAccount firstPositive = null;
+        int singleNegativeIndex = -1;
+        int singlePositiveIndex = -1;
+        int negativeCount = 0;
+        boolean hasCurrency = false;
+        for (int i = 0; i < accounts.size(); i++) {
+            LedgerTransactionAccount acc = accounts.get(i);
+            TransactionAccount item = new TransactionAccount(acc.getAccountName(),
+                    Misc.nullIsEmpty(acc.getCurrency()));
+            newList.add(item);
+
+            item.setAccountName(acc.getAccountName());
+            item.setComment(acc.getComment());
+            if (acc.isAmountSet()) {
+                item.setAmount(acc.getAmount());
+                if (acc.getAmount() < 0) {
+                    if (firstNegative == null) {
+                        firstNegative = item;
+                        singleNegativeIndex = i + 1;
+                    }
+                    else
+                        singleNegativeIndex = -1;
+                }
+                else {
+                    if (firstPositive == null) {
+                        firstPositive = item;
+                        singlePositiveIndex = i + 1;
+                    }
+                    else
+                        singlePositiveIndex = -1;
+                }
+            }
+            else
+                item.resetAmount();
+
+            if (item.getCurrency()
+                    .length() > 0)
+                hasCurrency = true;
+        }
+        if (BuildConfig.DEBUG)
+            dumpItemList("Loaded previous transaction", newList);
+
+        if (singleNegativeIndex != -1) {
+            firstNegative.resetAmount();
+            moveItemLast(newList, singleNegativeIndex);
+        }
+        else if (singlePositiveIndex != -1) {
+            firstPositive.resetAmount();
+            moveItemLast(newList, singlePositiveIndex);
+        }
+
+        final boolean foundTransactionHasCurrency = hasCurrency;
+        Misc.onMainThread(() -> {
+            setItems(newList);
+            noteFocusChanged(1, FocusedElement.Amount);
+            if (foundTransactionHasCurrency)
+                showCurrency.setValue(true);
+        });
+    }
+    /**
+     * A transaction is submittable if:
+     * 0) has description
+     * 1) has at least two account names
+     * 2) each row with amount has account name
+     * 3) for each commodity:
+     * 3a) amounts must balance to 0, or
+     * 3b) there must be exactly one empty amount (with account)
+     * 4) empty accounts with empty amounts are ignored
+     * Side effects:
+     * 5) a row with an empty account name or empty amount is guaranteed to exist for each
+     * commodity
+     * 6) at least two rows need to be present in the ledger
+     *
+     * @param list - the item list to check. Can be the displayed list or a list that will be
+     *             displayed soon
+     */
+    @SuppressLint("DefaultLocale")
+    void checkTransactionSubmittable(@Nullable List<Item> list) {
+        boolean workingWithLiveList = false;
+        if (list == null) {
+            list = copyList();
+            workingWithLiveList = true;
+        }
+
+        if (BuildConfig.DEBUG)
+            dumpItemList(String.format("Before submittable checks (%s)",
+                    workingWithLiveList ? "LIVE LIST" : "custom list"), list);
+
+        int accounts = 0;
+        final BalanceForCurrency balance = new BalanceForCurrency();
+        final String descriptionText = list.get(0)
+                                           .toTransactionHead()
+                                           .getDescription();
+        boolean submittable = true;
+        boolean listChanged = false;
+        final ItemsForCurrency itemsForCurrency = new ItemsForCurrency();
+        final ItemsForCurrency itemsWithEmptyAmountForCurrency = new ItemsForCurrency();
+        final ItemsForCurrency itemsWithAccountAndEmptyAmountForCurrency = new ItemsForCurrency();
+        final ItemsForCurrency itemsWithEmptyAccountForCurrency = new ItemsForCurrency();
+        final ItemsForCurrency itemsWithAmountForCurrency = new ItemsForCurrency();
+        final ItemsForCurrency itemsWithAccountForCurrency = new ItemsForCurrency();
+        final ItemsForCurrency emptyRowsForCurrency = new ItemsForCurrency();
+        final List<Item> emptyRows = new ArrayList<>();
+
+        try {
+            if ((descriptionText == null) || descriptionText.trim()
+                                                            .isEmpty())
+            {
+                Logger.debug("submittable", "Transaction not submittable: missing description");
+                submittable = false;
+            }
+
+            boolean hasInvalidAmount = false;
+
+            for (int i = 1; i < list.size(); i++) {
+                TransactionAccount item = list.get(i)
+                                              .toTransactionAccount();
+
+                String accName = item.getAccountName()
+                                     .trim();
+                String currName = item.getCurrency();
+
+                itemsForCurrency.add(currName, item);
+
+                if (accName.isEmpty()) {
+                    itemsWithEmptyAccountForCurrency.add(currName, item);
+
+                    if (item.isAmountSet()) {
+                        // 2) each amount has account name
+                        Logger.debug("submittable", String.format(
+                                "Transaction not submittable: row %d has no account name, but" +
+                                " has" + " amount %1.2f", i + 1, item.getAmount()));
+                        submittable = false;
+                    }
+                    else {
+                        emptyRowsForCurrency.add(currName, item);
+                    }
+                }
+                else {
+                    accounts++;
+                    itemsWithAccountForCurrency.add(currName, item);
+                }
+
+                if (item.isAmountSet() && item.isAmountValid()) {
+                    itemsWithAmountForCurrency.add(currName, item);
+                    balance.add(currName, item.getAmount());
+                }
+                else {
+                    if (!item.isAmountValid()) {
+                        Logger.debug("submittable",
+                                String.format("Not submittable: row %d has an invalid amount", i));
+                        submittable = false;
+                        hasInvalidAmount = true;
+                    }
+
+                    itemsWithEmptyAmountForCurrency.add(currName, item);
+
+                    if (!accName.isEmpty())
+                        itemsWithAccountAndEmptyAmountForCurrency.add(currName, item);
+                }
+            }
+
+            // 1) has at least two account names
+            if (accounts < 2) {
+                if (accounts == 0)
+                    Logger.debug("submittable", "Transaction not submittable: no account names");
+                else if (accounts == 1)
+                    Logger.debug("submittable",
+                            "Transaction not submittable: only one account name");
+                else
+                    Logger.debug("submittable",
+                            String.format("Transaction not submittable: only %d account names",
+                                    accounts));
+                submittable = false;
+            }
+
+            // 3) for each commodity:
+            // 3a) amount must balance to 0, or
+            // 3b) there must be exactly one empty amount (with account)
+            for (String balCurrency : itemsForCurrency.currencies()) {
+                float currencyBalance = balance.get(balCurrency);
+                if (Misc.isZero(currencyBalance)) {
+                    // remove hints from all amount inputs in that currency
+                    for (int i = 1; i < list.size(); i++) {
+                        TransactionAccount acc = list.get(i)
+                                                     .toTransactionAccount();
+                        if (Misc.equalStrings(acc.getCurrency(), balCurrency)) {
+                            if (BuildConfig.DEBUG)
+                                Logger.debug("submittable",
+                                        String.format(Locale.US, "Resetting hint of %d:'%s' [%s]",
+                                                i, Misc.nullIsEmpty(acc.getAccountName()),
+                                                balCurrency));
+                            // skip if the amount is set, in which case the hint is not
+                            // important/visible
+                            if (!acc.isAmountSet() && acc.amountHintIsSet &&
+                                !TextUtils.isEmpty(acc.getAmountHint()))
+                            {
+                                acc.setAmountHint(null);
+                                listChanged = true;
+                            }
+                        }
+                    }
+                }
+                else {
+                    List<Item> tmpList =
+                            itemsWithAccountAndEmptyAmountForCurrency.getList(balCurrency);
+                    int balanceReceiversCount = tmpList.size();
+                    if (balanceReceiversCount != 1) {
+                        if (BuildConfig.DEBUG) {
+                            if (balanceReceiversCount == 0)
+                                Logger.debug("submittable", String.format(
+                                        "Transaction not submittable [curr:%s]: non-zero balance " +
+                                        "with no empty amounts with accounts", balCurrency));
+                            else
+                                Logger.debug("submittable", String.format(
+                                        "Transaction not submittable [curr:%s]: non-zero balance " +
+                                        "with multiple empty amounts with accounts", balCurrency));
+                        }
+                        submittable = false;
+                    }
+
+                    List<Item> emptyAmountList =
+                            itemsWithEmptyAmountForCurrency.getList(balCurrency);
+
+                    // suggest off-balance amount to a row and remove hints on other rows
+                    Item receiver = null;
+                    if (!tmpList.isEmpty())
+                        receiver = tmpList.get(0);
+                    else if (!emptyAmountList.isEmpty())
+                        receiver = emptyAmountList.get(0);
+
+                    for (int i = 0; i < list.size(); i++) {
+                        Item item = list.get(i);
+                        if (!(item instanceof TransactionAccount))
+                            continue;
+
+                        TransactionAccount acc = item.toTransactionAccount();
+                        if (!Misc.equalStrings(acc.getCurrency(), balCurrency))
+                            continue;
+
+                        if (item == receiver) {
+                            final String hint = Data.formatNumber(-currencyBalance);
+                            if (!acc.isAmountHintSet() ||
+                                !Misc.equalStrings(acc.getAmountHint(), hint))
+                            {
+                                Logger.debug("submittable",
+                                        String.format("Setting amount hint of {%s} to %s [%s]", acc,
+                                                hint, balCurrency));
+                                acc.setAmountHint(hint);
+                                listChanged = true;
+                            }
+                        }
+                        else {
+                            if (BuildConfig.DEBUG)
+                                Logger.debug("submittable",
+                                        String.format("Resetting hint of '%s' [%s]",
+                                                Misc.nullIsEmpty(acc.getAccountName()),
+                                                balCurrency));
+                            if (acc.amountHintIsSet && !TextUtils.isEmpty(acc.getAmountHint())) {
+                                acc.setAmountHint(null);
+                                listChanged = true;
+                            }
+                        }
+                    }
+                }
+            }
+
+            // 5) a row with an empty account name or empty amount is guaranteed to exist for
+            // each commodity
+            if (!hasInvalidAmount) {
+                for (String balCurrency : balance.currencies()) {
+                    int currEmptyRows = itemsWithEmptyAccountForCurrency.size(balCurrency);
+                    int currRows = itemsForCurrency.size(balCurrency);
+                    int currAccounts = itemsWithAccountForCurrency.size(balCurrency);
+                    int currAmounts = itemsWithAmountForCurrency.size(balCurrency);
+                    if ((currEmptyRows == 0) &&
+                        ((currRows == currAccounts) || (currRows == currAmounts)))
+                    {
+                        // perhaps there already is an unused empty row for another currency that
+                        // is not used?
+//                        boolean foundIt = false;
+//                        for (Item item : emptyRows) {
+//                            Currency itemCurrency = item.getCurrency();
+//                            String itemCurrencyName =
+//                                    (itemCurrency == null) ? "" : itemCurrency.getName();
+//                            if (Misc.isZero(balance.get(itemCurrencyName))) {
+//                                item.setCurrency(Currency.loadByName(balCurrency));
+//                                item.setAmountHint(
+//                                        Data.formatNumber(-balance.get(balCurrency)));
+//                                foundIt = true;
+//                                break;
+//                            }
+//                        }
+//
+//                        if (!foundIt)
+                        final TransactionAccount newAcc = new TransactionAccount("", balCurrency);
+                        final float bal = balance.get(balCurrency);
+                        if (!Misc.isZero(bal) && currAmounts == currRows)
+                            newAcc.setAmountHint(Data.formatNumber(-bal));
+                        Logger.debug("submittable",
+                                String.format("Adding new item with %s for currency %s",
+                                        newAcc.getAmountHint(), balCurrency));
+                        list.add(newAcc);
+                        listChanged = true;
+                    }
+                }
+            }
+
+            // drop extra empty rows, not needed
+            for (String currName : emptyRowsForCurrency.currencies()) {
+                List<Item> emptyItems = emptyRowsForCurrency.getList(currName);
+                while ((list.size() > MIN_ITEMS) && (emptyItems.size() > 1)) {
+                    // the list is a copy, so the empty item is no longer present
+                    Item itemToRemove = emptyItems.remove(1);
+                    removeItemById(list, itemToRemove.id);
+                    listChanged = true;
+                }
+
+                // unused currency, remove last item (which is also an empty one)
+                if ((list.size() > MIN_ITEMS) && (emptyItems.size() == 1)) {
+                    List<Item> currItems = itemsForCurrency.getList(currName);
+
+                    if (currItems.size() == 1) {
+                        // the list is a copy, so the empty item is no longer present
+                        removeItemById(list, emptyItems.get(0).id);
+                        listChanged = true;
+                    }
+                }
+            }
+
+            // 6) at least two rows need to be present in the ledger
+            //    (the list also contains header and trailer)
+            while (list.size() < MIN_ITEMS) {
+                list.add(new TransactionAccount(""));
+                listChanged = true;
+            }
+
+            Logger.debug("submittable", submittable ? "YES" : "NO");
+            isSubmittable.setValue(submittable);
+
+            if (BuildConfig.DEBUG)
+                dumpItemList("After submittable checks", list);
+        }
+        catch (NumberFormatException e) {
+            Logger.debug("submittable", "NO (because of NumberFormatException)");
+            isSubmittable.setValue(false);
+        }
+        catch (Exception e) {
+            e.printStackTrace();
+            Logger.debug("submittable", "NO (because of an Exception)");
+            isSubmittable.setValue(false);
+        }
+
+        if (listChanged && workingWithLiveList) {
+            setItemsWithoutSubmittableChecks(list);
+        }
+    }
+    private void removeItemById(@NotNull List<Item> list, int id) {
+        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
+            list.removeIf(item -> item.id == id);
+        }
+        else {
+            for (Item item : list) {
+                if (item.id == id) {
+                    list.remove(item);
+                    break;
+                }
+            }
+        }
+    }
+    @SuppressLint("DefaultLocale")
+    private void dumpItemList(@NotNull String msg, @NotNull List<Item> list) {
+        Logger.debug("submittable", "== Dump of all items " + msg);
+        for (int i = 1; i < list.size(); i++) {
+            TransactionAccount item = list.get(i)
+                                          .toTransactionAccount();
+            Logger.debug("submittable", String.format("%d:%s", i, item.toString()));
+        }
+    }
+    public void setItemCurrency(int position, String newCurrency) {
+        TransactionAccount item = Objects.requireNonNull(items.getValue())
+                                         .get(position)
+                                         .toTransactionAccount();
+        final String oldCurrency = item.getCurrency();
+
+        if (Misc.equalStrings(oldCurrency, newCurrency))
+            return;
+
+        List<Item> newList = copyList();
+        newList.get(position)
+               .toTransactionAccount()
+               .setCurrency(newCurrency);
+
+        setItems(newList);
+    }
+    public boolean accountListIsEmpty() {
+        List<Item> items = Objects.requireNonNull(this.items.getValue());
+
+        for (Item item : items) {
+            if (!(item instanceof TransactionAccount))
+                continue;
+
+            if (!((TransactionAccount) item).isEmpty())
+                return false;
+        }
+
+        return true;
+    }
+
+    public static class FocusInfo {
+        int position;
+        FocusedElement element;
+        public FocusInfo(int position, @Nullable FocusedElement element) {
+            this.position = position;
+            this.element = element;
+        }
+    }
+
+    static abstract class Item {
+        private static int idDispenser = 0;
+        protected int id;
+        private Item() {
+            if (this instanceof TransactionHead)
+                id = 0;
+            else
+                synchronized (Item.class) {
+                    id = ++idDispenser;
+                }
+        }
+        public Item(int id) {
+            this.id = id;
+        }
+        public static Item from(Item origin) {
+            if (origin instanceof TransactionHead)
+                return new TransactionHead((TransactionHead) origin);
+            if (origin instanceof TransactionAccount)
+                return new TransactionAccount((TransactionAccount) origin);
+            throw new RuntimeException("Don't know how to handle " + origin);
+        }
+        private static void resetIdDispenser() {
+            idDispenser = 0;
+        }
+        public int getId() {
+            return id;
+        }
+        public abstract ItemType getType();
+        public TransactionHead toTransactionHead() {
+            if (this instanceof TransactionHead)
+                return (TransactionHead) this;
+
+            throw new IllegalStateException("Wrong item type " + this);
+        }
+        public TransactionAccount toTransactionAccount() {
+            if (this instanceof TransactionAccount)
+                return (TransactionAccount) this;
+
+            throw new IllegalStateException("Wrong item type " + this);
+        }
+        public boolean equalContents(@Nullable Object item) {
+            if (item == null)
+                return false;
+
+            if (!getClass().equals(item.getClass()))
+                return false;
+
+            // shortcut - comparing same instance
+            if (item == this)
+                return true;
+
+            if (this instanceof TransactionHead)
+                return ((TransactionHead) item).equalContents((TransactionHead) this);
+            if (this instanceof TransactionAccount)
+                return ((TransactionAccount) item).equalContents((TransactionAccount) this);
+
+            throw new RuntimeException("Don't know how to handle " + this);
+        }
+    }
+
+
+//==========================================================================================
+
+    public static class TransactionHead extends Item {
+        private SimpleDate date;
+        private String description;
+        private String comment;
+        TransactionHead(String description) {
+            super();
+            this.description = description;
+        }
+        public TransactionHead(TransactionHead origin) {
+            super(origin.id);
+            date = origin.date;
+            description = origin.description;
+            comment = origin.comment;
+        }
+        public SimpleDate getDate() {
+            return date;
+        }
+        public void setDate(SimpleDate date) {
+            this.date = date;
+        }
+        public void setDate(String text) throws ParseException {
+            if (Misc.emptyIsNull(text) == null) {
+                date = null;
+                return;
+            }
+
+            date = Globals.parseLedgerDate(text);
+        }
+        /**
+         * getFormattedDate()
+         *
+         * @return nicely formatted, shortest available date representation
+         */
+        String getFormattedDate() {
+            if (date == null)
+                return null;
+
+            Calendar today = GregorianCalendar.getInstance();
+
+            if (today.get(Calendar.YEAR) != date.year) {
+                return String.format(Locale.US, "%d/%02d/%02d", date.year, date.month, date.day);
+            }
+
+            if (today.get(Calendar.MONTH) + 1 != date.month) {
+                return String.format(Locale.US, "%d/%02d", date.month, date.day);
+            }
+
+            return String.valueOf(date.day);
+        }
+        @NonNull
+        @Override
+        public String toString() {
+            @SuppressLint("DefaultLocale") StringBuilder b = new StringBuilder(
+                    String.format("id:%d/%s", id, Integer.toHexString(hashCode())));
+
+            if (TextUtils.isEmpty(description))
+                b.append(" «no description»");
+            else
+                b.append(String.format(" '%s'", description));
+
+            if (date != null)
+                b.append(String.format("@%s", date));
+
+            if (!TextUtils.isEmpty(comment))
+                b.append(String.format(" /%s/", comment));
+
+            return b.toString();
+        }
+        public String getDescription() {
+            return description;
+        }
+        public void setDescription(String description) {
+            this.description = description;
+        }
+        public String getComment() {
+            return comment;
+        }
+        public void setComment(String comment) {
+            this.comment = comment;
+        }
+        @Override
+        public ItemType getType() {
+            return ItemType.generalData;
+        }
+        public LedgerTransaction asLedgerTransaction() {
+            return new LedgerTransaction(0, (date == null) ? SimpleDate.today() : date, description,
+                    Objects.requireNonNull(Data.getProfile()));
+        }
+        public boolean equalContents(TransactionHead other) {
+            if (other == null)
+                return false;
+
+            return Objects.equals(date, other.date) &&
+                   Misc.equalStrings(description, other.description) &&
+                   Misc.equalStrings(comment, other.comment);
+        }
+    }
+
+    public static class TransactionAccount extends Item {
+        private String accountName;
+        private String amountHint;
+        private String comment;
+        @NotNull
+        private String currency = "";
+        private float amount;
+        private boolean amountSet;
+        private boolean amountValid = true;
+        @NotNull
+        private String amountText = "";
+        private FocusedElement focusedElement = FocusedElement.Account;
+        private boolean amountHintIsSet = false;
+        private boolean isLast = false;
+        private int accountNameCursorPosition;
+        public TransactionAccount(TransactionAccount origin) {
+            super(origin.id);
+            accountName = origin.accountName;
+            amount = origin.amount;
+            amountSet = origin.amountSet;
+            amountHint = origin.amountHint;
+            amountHintIsSet = origin.amountHintIsSet;
+            amountText = origin.amountText;
+            comment = origin.comment;
+            currency = origin.currency;
+            amountValid = origin.amountValid;
+            focusedElement = origin.focusedElement;
+            isLast = origin.isLast;
+            accountNameCursorPosition = origin.accountNameCursorPosition;
+        }
+        public TransactionAccount(String accountName) {
+            super();
+            this.accountName = accountName;
+        }
+        public TransactionAccount(String accountName, @NotNull String currency) {
+            super();
+            this.accountName = accountName;
+            this.currency = currency;
+        }
+        public @NotNull String getAmountText() {
+            return amountText;
+        }
+        public void setAmountText(@NotNull String amountText) {
+            this.amountText = amountText;
+        }
+        public boolean setAndCheckAmountText(@NotNull String amountText) {
+            String amtText = amountText.trim();
+            this.amountText = amtText;
+
+            boolean significantChange = false;
+
+            if (amtText.isEmpty()) {
+                if (amountSet) {
+                    significantChange = true;
+                }
+                resetAmount();
+            }
+            else {
+                try {
+                    amtText = amtText.replace(Data.getDecimalSeparator(), Data.decimalDot);
+                    final float parsedAmount = Float.parseFloat(amtText);
+                    if (!amountSet || !amountValid || !Misc.equalFloats(parsedAmount, amount))
+                        significantChange = true;
+                    amount = parsedAmount;
+                    amountSet = true;
+                    amountValid = true;
+                }
+                catch (NumberFormatException e) {
+                    Logger.debug("new-trans", String.format(
+                            "assuming amount is not set due to number format exception. " +
+                            "input was '%s'", amtText));
+                    if (amountValid) // it was valid and now it's not
+                        significantChange = true;
+                    amountValid = false;
+                }
+            }
+
+            return significantChange;
+        }
+        public boolean isLast() {
+            return isLast;
+        }
+        public boolean isAmountSet() {
+            return amountSet;
+        }
+        public String getAccountName() {
+            return accountName;
+        }
+        public void setAccountName(String accountName) {
+            this.accountName = accountName;
+        }
+        public float getAmount() {
+            if (!amountSet)
+                throw new IllegalStateException("Amount is not set");
+            return amount;
+        }
+        public void setAmount(float amount) {
+            this.amount = amount;
+            amountSet = true;
+            amountValid = true;
+            amountText = Data.formatNumber(amount);
+        }
+        public void resetAmount() {
+            amountSet = false;
+            amountValid = true;
+            amountText = "";
+        }
+        @Override
+        public ItemType getType() {
+            return ItemType.transactionRow;
+        }
+        public String getAmountHint() {
+            return amountHint;
+        }
+        public void setAmountHint(String amountHint) {
+            this.amountHint = amountHint;
+            amountHintIsSet = !TextUtils.isEmpty(amountHint);
+        }
+        public String getComment() {
+            return comment;
+        }
+        public void setComment(String comment) {
+            this.comment = comment;
+        }
+        @NotNull
+        public String getCurrency() {
+            return currency;
+        }
+        public void setCurrency(@org.jetbrains.annotations.Nullable String currency) {
+            this.currency = Misc.nullIsEmpty(currency);
+        }
+        public boolean isAmountValid() {
+            return amountValid;
+        }
+        public void setAmountValid(boolean amountValid) {
+            this.amountValid = amountValid;
+        }
+        public FocusedElement getFocusedElement() {
+            return focusedElement;
+        }
+        public void setFocusedElement(FocusedElement focusedElement) {
+            this.focusedElement = focusedElement;
+        }
+        public boolean isAmountHintSet() {
+            return amountHintIsSet;
+        }
+        public void setAmountHintIsSet(boolean amountHintIsSet) {
+            this.amountHintIsSet = amountHintIsSet;
+        }
+        public boolean isEmpty() {
+            return !amountSet && Misc.emptyIsNull(accountName) == null &&
+                   Misc.emptyIsNull(comment) == null;
+        }
+        @SuppressLint("DefaultLocale")
+        @Override
+        @NotNull
+        public String toString() {
+            StringBuilder b = new StringBuilder();
+            b.append(String.format("id:%d/%s", id, Integer.toHexString(hashCode())));
+            if (!TextUtils.isEmpty(accountName))
+                b.append(String.format(" acc'%s'", accountName));
+
+            if (amountSet)
+                b.append(amountText)
+                 .append(" [")
+                 .append(amountValid ? "valid" : "invalid")
+                 .append("] ")
+                 .append(String.format(Locale.ROOT, " {raw %4.2f}", amount));
+            else if (amountHintIsSet)
+                b.append(String.format(" (hint %s)", amountHint));
+
+            if (!TextUtils.isEmpty(currency))
+                b.append(" ")
+                 .append(currency);
+
+            if (!TextUtils.isEmpty(comment))
+                b.append(String.format(" /%s/", comment));
+
+            if (isLast)
+                b.append(" last");
+
+            return b.toString();
+        }
+        public boolean equalContents(TransactionAccount other) {
+            if (other == null)
+                return false;
+
+            boolean equal = Misc.equalStrings(accountName, other.accountName);
+            equal = equal && Misc.equalStrings(comment, other.comment) &&
+                    (amountSet ? other.amountSet && amountValid == other.amountValid &&
+                                 Misc.equalStrings(amountText, other.amountText)
+                               : !other.amountSet);
+
+            // compare amount hint only if there is no amount
+            if (!amountSet)
+                equal = equal && (amountHintIsSet ? other.amountHintIsSet &&
+                                                    Misc.equalStrings(amountHint, other.amountHint)
+                                                  : !other.amountHintIsSet);
+            equal = equal && Misc.equalStrings(currency, other.currency) && isLast == other.isLast;
+
+            Logger.debug("new-trans",
+                    String.format("Comparing {%s} and {%s}: %s", this, other, equal));
+            return equal;
+        }
+        public int getAccountNameCursorPosition() {
+            return accountNameCursorPosition;
+        }
+        public void setAccountNameCursorPosition(int position) {
+            this.accountNameCursorPosition = position;
+        }
+    }
+
+    private static class BalanceForCurrency {
+        private final HashMap<String, Float> hashMap = new HashMap<>();
+        float get(String currencyName) {
+            Float f = hashMap.get(currencyName);
+            if (f == null) {
+                f = 0f;
+                hashMap.put(currencyName, f);
+            }
+            return f;
+        }
+        void add(String currencyName, float amount) {
+            hashMap.put(currencyName, get(currencyName) + amount);
+        }
+        Set<String> currencies() {
+            return hashMap.keySet();
+        }
+        boolean containsCurrency(String currencyName) {
+            return hashMap.containsKey(currencyName);
+        }
+    }
+
+    private static class ItemsForCurrency {
+        private final HashMap<@NotNull String, List<Item>> hashMap = new HashMap<>();
+        @NonNull
+        List<NewTransactionModel.Item> getList(@NotNull String currencyName) {
+            List<NewTransactionModel.Item> list = hashMap.get(currencyName);
+            if (list == null) {
+                list = new ArrayList<>();
+                hashMap.put(currencyName, list);
+            }
+            return list;
+        }
+        void add(@NotNull String currencyName, @NonNull NewTransactionModel.Item item) {
+            getList(Objects.requireNonNull(currencyName)).add(item);
+        }
+        int size(@NotNull String currencyName) {
+            return this.getList(Objects.requireNonNull(currencyName))
+                       .size();
+        }
+        Set<String> currencies() {
+            return hashMap.keySet();
+        }
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/profiles/ProfileDetailActivity.java b/app/src/main/java/net/ktnx/mobileledger/ui/profiles/ProfileDetailActivity.java
new file mode 100644 (file)
index 0000000..42c8490
--- /dev/null
@@ -0,0 +1,152 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui.profiles;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuItem;
+
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.widget.Toolbar;
+import androidx.lifecycle.ViewModelProvider;
+
+import com.google.android.material.appbar.CollapsingToolbarLayout;
+
+import net.ktnx.mobileledger.R;
+import net.ktnx.mobileledger.db.DB;
+import net.ktnx.mobileledger.db.Profile;
+import net.ktnx.mobileledger.model.Data;
+import net.ktnx.mobileledger.ui.activity.CrashReportingActivity;
+import net.ktnx.mobileledger.utils.Colors;
+import net.ktnx.mobileledger.utils.Logger;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Locale;
+
+import static net.ktnx.mobileledger.utils.Logger.debug;
+
+/**
+ * An activity representing a single Profile detail screen. This
+ * activity is only used on narrow width devices. On tablet-size devices,
+ * item details are presented side-by-side with a list of items
+ * in a ProfileListActivity (not really).
+ */
+public class ProfileDetailActivity extends CrashReportingActivity {
+    private static final String TAG = "profile-det-act";
+    private ProfileDetailFragment mFragment;
+    public static void start(Context context, @Nullable Profile profile) {
+        Intent starter = new Intent(context, ProfileDetailActivity.class);
+        if (profile != null) {
+            starter.putExtra(ProfileDetailFragment.ARG_ITEM_ID, profile.getId());
+            starter.putExtra(ProfileDetailFragment.ARG_HUE, profile.getTheme());
+            Logger.debug(TAG,
+                    String.format(Locale.ROOT, "Starting profile editor for profile %d, theme %d",
+                            profile.getId(), profile.getTheme()));
+        }
+        else
+            Logger.debug(TAG, "Starting empty profile editor");
+        context.startActivity(starter);
+    }
+    @NotNull
+    private ProfileDetailModel getModel() {
+        return new ViewModelProvider(this).get(ProfileDetailModel.class);
+    }
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        final long id = getIntent().getLongExtra(ProfileDetailFragment.ARG_ITEM_ID, -1);
+
+        DB.get()
+          .getProfileDAO()
+          .getById(id)
+          .observe(this, this::setProfile);
+
+        int themeHue = getIntent().getIntExtra(ProfileDetailFragment.ARG_HUE, -1);
+
+        super.onCreate(savedInstanceState);
+        if (themeHue == -1) {
+            themeHue = Colors.getNewProfileThemeHue(Data.profiles.getValue());
+        }
+        Colors.setupTheme(this, themeHue);
+        final ProfileDetailModel model = getModel();
+        model.initialThemeHue = themeHue;
+        model.setThemeId(themeHue);
+        setContentView(R.layout.activity_profile_detail);
+        Toolbar toolbar = findViewById(R.id.detail_toolbar);
+        setSupportActionBar(toolbar);
+
+
+        // Show the Up button in the action bar.
+        ActionBar actionBar = getSupportActionBar();
+        if (actionBar != null) {
+            actionBar.setDisplayHomeAsUpEnabled(true);
+        }
+
+        // savedInstanceState is non-null when there is fragment state
+        // saved from previous configurations of this activity
+        // (e.g. when rotating the screen from portrait to landscape).
+        // In this case, the fragment will automatically be re-added
+        // to its container so we don't need to manually add it.
+        // For more information, see the Fragments API guide at:
+        //
+        // http://developer.android.com/guide/components/fragments.html
+        //
+        if (savedInstanceState == null) {
+            // Create the detail fragment and add it to the activity
+            // using a fragment transaction.
+            Bundle arguments = new Bundle();
+            arguments.putInt(ProfileDetailFragment.ARG_HUE, themeHue);
+            mFragment = new ProfileDetailFragment();
+            mFragment.setArguments(arguments);
+            getSupportFragmentManager().beginTransaction()
+                                       .add(R.id.profile_detail_container, mFragment)
+                                       .commit();
+        }
+    }
+    private void setProfile(Profile profile) {
+        ProfileDetailModel model = new ViewModelProvider(this).get(ProfileDetailModel.class);
+        CollapsingToolbarLayout appBarLayout = findViewById(R.id.toolbar_layout);
+        if (appBarLayout != null) {
+            if (profile != null)
+                appBarLayout.setTitle(profile.getName());
+            else
+                appBarLayout.setTitle(getResources().getString(R.string.new_profile_title));
+        }
+        model.setValuesFromProfile(profile);
+    }
+    @Override
+    public boolean onCreateOptionsMenu(Menu menu) {
+        super.onCreateOptionsMenu(menu);
+        debug("profiles", "[activity] Creating profile details options menu");
+        if (mFragment != null)
+            mFragment.onCreateOptionsMenu(menu, getMenuInflater());
+
+        return true;
+    }
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        if (item.getItemId() == android.R.id.home) {
+            finish();
+            return true;
+        }
+        return super.onOptionsItemSelected(item);
+    }
+}
index 9b0b60b70b00a874277a47418058c6d40068a627..99934543e7b544bf876fcc0010282d9cb373801e 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2020 Damyan Ivanov.
+ * Copyright © 2021 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
@@ -19,17 +19,18 @@ package net.ktnx.mobileledger.ui.profiles;
 
 import android.app.Activity;
 import android.app.AlertDialog;
+import android.app.backup.BackupManager;
 import android.graphics.Typeface;
 import android.os.Bundle;
 import android.text.Editable;
 import android.text.TextWatcher;
+import android.view.LayoutInflater;
 import android.view.Menu;
 import android.view.MenuInflater;
 import android.view.MenuItem;
 import android.view.View;
-import android.widget.LinearLayout;
+import android.view.ViewGroup;
 import android.widget.PopupMenu;
-import android.widget.Switch;
 import android.widget.TextView;
 
 import androidx.annotation.NonNull;
@@ -40,18 +41,21 @@ import androidx.fragment.app.FragmentActivity;
 import androidx.lifecycle.LifecycleOwner;
 import androidx.lifecycle.ViewModelProvider;
 
-import com.google.android.material.appbar.CollapsingToolbarLayout;
 import com.google.android.material.floatingactionbutton.FloatingActionButton;
 import com.google.android.material.textfield.TextInputLayout;
 
 import net.ktnx.mobileledger.BuildConfig;
 import net.ktnx.mobileledger.R;
-import net.ktnx.mobileledger.async.SendTransactionTask;
+import net.ktnx.mobileledger.dao.BaseDAO;
+import net.ktnx.mobileledger.dao.ProfileDAO;
+import net.ktnx.mobileledger.databinding.ProfileDetailBinding;
+import net.ktnx.mobileledger.db.DB;
+import net.ktnx.mobileledger.db.Profile;
+import net.ktnx.mobileledger.json.API;
 import net.ktnx.mobileledger.model.Data;
-import net.ktnx.mobileledger.model.MobileLedgerProfile;
+import net.ktnx.mobileledger.model.FutureDates;
 import net.ktnx.mobileledger.ui.CurrencySelectorFragment;
 import net.ktnx.mobileledger.ui.HueRingDialog;
-import net.ktnx.mobileledger.ui.activity.ProfileDetailActivity;
 import net.ktnx.mobileledger.utils.Colors;
 import net.ktnx.mobileledger.utils.Misc;
 
@@ -60,9 +64,8 @@ import org.jetbrains.annotations.NotNull;
 
 import java.net.MalformedURLException;
 import java.net.URL;
-import java.util.ArrayList;
+import java.util.List;
 import java.util.Objects;
-import java.util.UUID;
 
 import static net.ktnx.mobileledger.utils.Logger.debug;
 
@@ -80,25 +83,9 @@ public class ProfileDetailFragment extends Fragment {
     public static final String ARG_HUE = "hue";
     @NonNls
 
-    private MobileLedgerProfile mProfile;
-    private TextView url;
-    private TextView defaultCommodity;
     private boolean defaultCommoditySet;
-    private TextInputLayout urlLayout;
-    private LinearLayout authParams;
-    private Switch useAuthentication;
-    private TextView userName;
-    private TextInputLayout userNameLayout;
-    private TextView password;
-    private TextInputLayout passwordLayout;
-    private TextView profileName;
-    private TextInputLayout profileNameLayout;
-    private TextView preferredAccountsFilter;
-    private View huePickerView;
-    private View insecureWarningText;
-    private TextView futureDatesText;
-    private TextView apiVersionText;
     private boolean syncingModelFromUI = false;
+    private ProfileDetailBinding binding;
     /**
      * Mandatory empty constructor for the fragment manager to instantiate the
      * fragment (e.g. upon screen orientation changes).
@@ -113,31 +100,39 @@ public class ProfileDetailFragment extends Fragment {
         inflater.inflate(R.menu.profile_details, menu);
         final MenuItem menuDeleteProfile = menu.findItem(R.id.menuDelete);
         menuDeleteProfile.setOnMenuItemClickListener(item -> onDeleteProfile());
-        final ArrayList<MobileLedgerProfile> profiles = Data.profiles.getValue();
+        final List<Profile> profiles = Data.profiles.getValue();
 
-        if (BuildConfig.DEBUG) {
-            final MenuItem menuWipeProfileData = menu.findItem(R.id.menuWipeData);
+        final MenuItem menuWipeProfileData = menu.findItem(R.id.menuWipeData);
+        if (BuildConfig.DEBUG)
             menuWipeProfileData.setOnMenuItemClickListener(ignored -> onWipeDataMenuClicked());
-            menuWipeProfileData.setVisible(mProfile != null);
-        }
+
+        getModel().getProfileId()
+                  .observe(getViewLifecycleOwner(), id -> {
+                      menuDeleteProfile.setVisible(id > 0);
+                      if (BuildConfig.DEBUG)
+                          menuWipeProfileData.setVisible(id > 0);
+                  });
     }
     private boolean onDeleteProfile() {
         AlertDialog.Builder builder = new AlertDialog.Builder(getContext());
-        builder.setTitle(mProfile.getName());
+        @NotNull ProfileDetailModel model = getModel();
+        builder.setTitle(model.getProfileName());
         builder.setMessage(R.string.remove_profile_dialog_message);
         builder.setPositiveButton(R.string.Remove, (dialog, which) -> {
-            debug("profiles", String.format("[fragment] removing profile %s", mProfile.getUuid()));
-            mProfile.removeFromDB();
-            ArrayList<MobileLedgerProfile> oldList = Data.profiles.getValue();
-            if (oldList == null)
-                throw new AssertionError();
-            ArrayList<MobileLedgerProfile> newList = new ArrayList<>(oldList);
-            newList.remove(mProfile);
-            Data.profiles.setValue(newList);
-            if (mProfile.equals(Data.getProfile())) {
-                debug("profiles", "[fragment] setting current profile to 0");
-                Data.setCurrentProfile(newList.get(0));
-            }
+            final long profileId = Objects.requireNonNull(model.getProfileId()
+                                                               .getValue());
+            debug("profiles", String.format("[fragment] removing profile %s", profileId));
+            ProfileDAO dao = DB.get()
+                               .getProfileDAO();
+            dao.getById(profileId)
+               .observe(getViewLifecycleOwner(), profile -> {
+                   if (profile != null)
+                       BaseDAO.runAsync(() -> DB.get()
+                                                .runInTransaction(() -> {
+                                                    dao.deleteSync(profile);
+                                                    dao.updateOrderSync(dao.getAllOrderedSync());
+                                                }));
+               });
 
             final FragmentActivity activity = getActivity();
             if (activity != null)
@@ -148,25 +143,16 @@ public class ProfileDetailFragment extends Fragment {
     }
     private boolean onWipeDataMenuClicked() {
         // this is a development option, so no confirmation
-        mProfile.wipeAllData();
-        if (mProfile.equals(Data.getProfile()))
-            triggerProfileChange();
+        DB.get()
+          .getProfileDAO()
+          .getById(Objects.requireNonNull(getModel().getProfileId()
+                                                    .getValue()))
+          .observe(getViewLifecycleOwner(), profile -> {
+              if (profile != null)
+                  profile.wipeAllData();
+          });
         return true;
     }
-    private void triggerProfileChange() {
-        int index = Data.getProfileIndex(mProfile);
-        MobileLedgerProfile newProfile = new MobileLedgerProfile(mProfile);
-        final ArrayList<MobileLedgerProfile> profiles =
-                Objects.requireNonNull(Data.profiles.getValue());
-        profiles.set(index, newProfile);
-
-        ProfilesRecyclerViewAdapter viewAdapter = ProfilesRecyclerViewAdapter.getInstance();
-        if (viewAdapter != null)
-            viewAdapter.notifyItemChanged(index);
-
-        if (mProfile.equals(Data.getProfile()))
-            Data.setCurrentProfile(newProfile);
-    }
     private void hookTextChangeSyncRoutine(TextView view, TextChangeSyncRoutine syncRoutine) {
         view.addTextChangedListener(new TextWatcher() {
             @Override
@@ -177,189 +163,153 @@ public class ProfileDetailFragment extends Fragment {
             public void afterTextChanged(Editable s) { syncRoutine.onTextChanged(s.toString());}
         });
     }
+    @Nullable
     @Override
-    public void onActivityCreated(@Nullable Bundle savedInstanceState) {
-        super.onActivityCreated(savedInstanceState);
+    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
+                             @Nullable Bundle savedInstanceState) {
+        binding = ProfileDetailBinding.inflate(inflater, container, false);
+
+        return binding.getRoot();
+    }
+    @Override
+    public void onViewCreated(@NotNull View view, @Nullable Bundle savedInstanceState) {
+        super.onViewCreated(view, savedInstanceState);
         Activity context = getActivity();
         if (context == null)
             return;
 
-        if ((getArguments() != null) && getArguments().containsKey(ARG_ITEM_ID)) {
-            int index = getArguments().getInt(ARG_ITEM_ID, -1);
-            ArrayList<MobileLedgerProfile> profiles = Data.profiles.getValue();
-            if ((profiles != null) && (index != -1) && (index < profiles.size()))
-                mProfile = profiles.get(index);
-
-            Activity activity = this.getActivity();
-            if (activity == null)
-                throw new AssertionError();
-            CollapsingToolbarLayout appBarLayout = activity.findViewById(R.id.toolbar_layout);
-            if (appBarLayout != null) {
-                if (mProfile != null)
-                    appBarLayout.setTitle(mProfile.getName());
-                else
-                    appBarLayout.setTitle(getResources().getString(R.string.new_profile_title));
-            }
-        }
-
         final LifecycleOwner viewLifecycleOwner = getViewLifecycleOwner();
         final ProfileDetailModel model = getModel();
 
         model.observeDefaultCommodity(viewLifecycleOwner, c -> {
             if (c != null)
-                setDefaultCommodity(c.getName());
+                setDefaultCommodity(c);
             else
                 resetDefaultCommodity();
         });
 
-        FloatingActionButton fab = context.findViewById(R.id.fab);
+        FloatingActionButton fab = context.findViewById(R.id.fabAdd);
         fab.setOnClickListener(v -> onSaveFabClicked());
 
-        profileName = context.findViewById(R.id.profile_name);
-        hookTextChangeSyncRoutine(profileName, model::setProfileName);
+        hookTextChangeSyncRoutine(binding.profileName, model::setProfileName);
         model.observeProfileName(viewLifecycleOwner, pn -> {
-            if (!Misc.equalStrings(pn, profileName.getText()))
-                profileName.setText(pn);
+            if (!Misc.equalStrings(pn, Misc.nullIsEmpty(binding.profileName.getText())))
+                binding.profileName.setText(pn);
         });
 
-        profileNameLayout = context.findViewById(R.id.profile_name_layout);
-
-        url = context.findViewById(R.id.url);
-        hookTextChangeSyncRoutine(url, model::setUrl);
+        hookTextChangeSyncRoutine(binding.url, model::setUrl);
         model.observeUrl(viewLifecycleOwner, u -> {
-            if (!Misc.equalStrings(u, url.getText()))
-                url.setText(u);
+            if (!Misc.equalStrings(u, Misc.nullIsEmpty(binding.url.getText())))
+                binding.url.setText(u);
         });
 
-        urlLayout = context.findViewById(R.id.url_layout);
-
-        context.findViewById(R.id.default_commodity_layout)
-               .setOnClickListener(v -> {
-                   CurrencySelectorFragment cpf = CurrencySelectorFragment.newInstance(
-                           CurrencySelectorFragment.DEFAULT_COLUMN_COUNT, false);
-                   cpf.setOnCurrencySelectedListener(model::setDefaultCommodity);
-                   final AppCompatActivity activity = (AppCompatActivity) v.getContext();
-                   cpf.show(activity.getSupportFragmentManager(), "currency-selector");
-               });
+        binding.defaultCommodityLayout.setOnClickListener(v -> {
+            CurrencySelectorFragment cpf = CurrencySelectorFragment.newInstance(
+                    CurrencySelectorFragment.DEFAULT_COLUMN_COUNT, false);
+            cpf.setOnCurrencySelectedListener(model::setDefaultCommodity);
+            final AppCompatActivity activity = (AppCompatActivity) v.getContext();
+            cpf.show(activity.getSupportFragmentManager(), "currency-selector");
+        });
 
-        Switch showCommodityByDefault = context.findViewById(R.id.profile_show_commodity);
-        showCommodityByDefault.setOnCheckedChangeListener(
+        binding.profileShowCommodity.setOnCheckedChangeListener(
                 (buttonView, isChecked) -> model.setShowCommodityByDefault(isChecked));
-        model.observeShowCommodityByDefault(viewLifecycleOwner, showCommodityByDefault::setChecked);
-
-        View postingSubItems = context.findViewById(R.id.posting_sub_items);
+        model.observeShowCommodityByDefault(viewLifecycleOwner,
+                binding.profileShowCommodity::setChecked);
 
-        Switch postingPermitted = context.findViewById(R.id.profile_permit_posting);
         model.observePostingPermitted(viewLifecycleOwner, isChecked -> {
-            postingPermitted.setChecked(isChecked);
-            postingSubItems.setVisibility(isChecked ? View.VISIBLE : View.GONE);
+            binding.profilePermitPosting.setChecked(isChecked);
+            binding.postingSubItems.setVisibility(isChecked ? View.VISIBLE : View.GONE);
         });
-        postingPermitted.setOnCheckedChangeListener(
+        binding.profilePermitPosting.setOnCheckedChangeListener(
                 ((buttonView, isChecked) -> model.setPostingPermitted(isChecked)));
 
-        Switch showCommentsByDefault = context.findViewById(R.id.profile_show_comments);
-        model.observeShowCommentsByDefault(viewLifecycleOwner, showCommentsByDefault::setChecked);
-        showCommentsByDefault.setOnCheckedChangeListener(
+        model.observeShowCommentsByDefault(viewLifecycleOwner,
+                binding.profileShowComments::setChecked);
+        binding.profileShowComments.setOnCheckedChangeListener(
                 ((buttonView, isChecked) -> model.setShowCommentsByDefault(isChecked)));
 
-        defaultCommodity = context.findViewById(R.id.default_commodity_text);
-
-        futureDatesText = context.findViewById(R.id.future_dates_text);
-        context.findViewById(R.id.future_dates_layout)
-               .setOnClickListener(v -> {
-                   MenuInflater mi = new MenuInflater(context);
-                   PopupMenu menu = new PopupMenu(context, v);
-                   menu.inflate(R.menu.future_dates);
-                   menu.setOnMenuItemClickListener(item -> {
-                       model.setFutureDates(futureDatesSettingFromMenuItemId(item.getItemId()));
-                       return true;
-                   });
-                   menu.show();
-               });
+        binding.futureDatesLayout.setOnClickListener(v -> {
+            MenuInflater mi = new MenuInflater(context);
+            PopupMenu menu = new PopupMenu(context, v);
+            menu.inflate(R.menu.future_dates);
+            menu.setOnMenuItemClickListener(item -> {
+                model.setFutureDates(futureDatesSettingFromMenuItemId(item.getItemId()));
+                return true;
+            });
+            menu.show();
+        });
         model.observeFutureDates(viewLifecycleOwner,
-                v -> futureDatesText.setText(v.getText(getResources())));
+                v -> binding.futureDatesText.setText(v.getText(getResources())));
 
-        apiVersionText = context.findViewById(R.id.api_version_text);
         model.observeApiVersion(viewLifecycleOwner,
-                apiVer -> apiVersionText.setText(apiVer.getDescription(getResources())));
-        context.findViewById(R.id.api_version_label)
-               .setOnClickListener(this::chooseAPIVersion);
-        context.findViewById(R.id.api_version_text)
-               .setOnClickListener(this::chooseAPIVersion);
+                apiVer -> binding.apiVersionText.setText(apiVer.getDescription(getResources())));
+        binding.apiVersionLabel.setOnClickListener(this::chooseAPIVersion);
+        binding.apiVersionText.setOnClickListener(this::chooseAPIVersion);
 
-        TextView detectedApiVersion = context.findViewById(R.id.detected_version_text);
+        binding.serverVersionLabel.setOnClickListener(v -> model.triggerVersionDetection());
         model.observeDetectedVersion(viewLifecycleOwner, ver -> {
             if (ver == null)
-                detectedApiVersion.setText(context.getResources()
-                                                  .getString(R.string.api_version_unknown_label));
-            else if (ver.isPre_1_20())
-                detectedApiVersion.setText(context.getResources()
-                                                  .getString(R.string.api_pre_1_19));
+                binding.detectedServerVersionText.setText(context.getResources()
+                                                                 .getString(
+                                                                         R.string.server_version_unknown_label));
+            else if (ver.isPre_1_20_1())
+                binding.detectedServerVersionText.setText(context.getResources()
+                                                                 .getString(
+                                                                         R.string.detected_server_pre_1_20_1));
             else
-                detectedApiVersion.setText(ver.toString());
+                binding.detectedServerVersionText.setText(ver.toString());
         });
-        detectedApiVersion.setOnClickListener(v -> model.triggerVersionDetection());
-        context.findViewById(R.id.api_version_detect_button)
-               .setOnClickListener(v -> model.triggerVersionDetection());
-
-        authParams = context.findViewById(R.id.auth_params);
-
-        useAuthentication = context.findViewById(R.id.enable_http_auth);
-        useAuthentication.setOnCheckedChangeListener((buttonView, isChecked) -> {
+        binding.detectedServerVersionText.setOnClickListener(v -> model.triggerVersionDetection());
+        binding.serverVersionDetectButton.setOnClickListener(v -> model.triggerVersionDetection());
+        model.observeDetectingHledgerVersion(viewLifecycleOwner,
+                running -> binding.serverVersionDetectButton.setVisibility(
+                        running ? View.VISIBLE : View.INVISIBLE));
+
+        binding.enableHttpAuth.setOnCheckedChangeListener((buttonView, isChecked) -> {
+            boolean wasOn = model.getUseAuthentication();
             model.setUseAuthentication(isChecked);
-            if (isChecked)
-                userName.requestFocus();
+            if (!wasOn && isChecked)
+                binding.authUserName.requestFocus();
         });
         model.observeUseAuthentication(viewLifecycleOwner, isChecked -> {
-            useAuthentication.setChecked(isChecked);
-            authParams.setVisibility(isChecked ? View.VISIBLE : View.GONE);
+            binding.enableHttpAuth.setChecked(isChecked);
+            binding.authParams.setVisibility(isChecked ? View.VISIBLE : View.GONE);
             checkInsecureSchemeWithAuth();
         });
 
-        userName = context.findViewById(R.id.auth_user_name);
         model.observeUserName(viewLifecycleOwner, text -> {
-            if (!Misc.equalStrings(text, userName.getText()))
-                userName.setText(text);
+            if (!Misc.equalStrings(text, Misc.nullIsEmpty(binding.authUserName.getText())))
+                binding.authUserName.setText(text);
         });
-        hookTextChangeSyncRoutine(userName, model::setAuthUserName);
-        userNameLayout = context.findViewById(R.id.auth_user_name_layout);
+        hookTextChangeSyncRoutine(binding.authUserName, model::setAuthUserName);
 
-        password = context.findViewById(R.id.password);
         model.observePassword(viewLifecycleOwner, text -> {
-            if (!Misc.equalStrings(text, password.getText()))
-                password.setText(text);
+            if (!Misc.equalStrings(text, Misc.nullIsEmpty(binding.password.getText())))
+                binding.password.setText(text);
         });
-        hookTextChangeSyncRoutine(password, model::setAuthPassword);
-        passwordLayout = context.findViewById(R.id.password_layout);
+        hookTextChangeSyncRoutine(binding.password, model::setAuthPassword);
 
-        huePickerView = context.findViewById(R.id.btn_pick_ring_color);
         model.observeThemeId(viewLifecycleOwner, themeId -> {
             final int hue = (themeId == -1) ? Colors.DEFAULT_HUE_DEG : themeId;
             final int profileColor = Colors.getPrimaryColorForHue(hue);
-            huePickerView.setBackgroundColor(profileColor);
-            huePickerView.setTag(hue);
+            binding.btnPickRingColor.setBackgroundColor(profileColor);
+            binding.btnPickRingColor.setTag(hue);
         });
 
-        preferredAccountsFilter = context.findViewById(R.id.preferred_accounts_filter_filter);
         model.observePreferredAccountsFilter(viewLifecycleOwner, text -> {
-            if (!Misc.equalStrings(text, preferredAccountsFilter.getText()))
-                preferredAccountsFilter.setText(text);
+            if (!Misc.equalStrings(text,
+                    Misc.nullIsEmpty(binding.preferredAccountsFilter.getText())))
+                binding.preferredAccountsFilter.setText(text);
         });
-        hookTextChangeSyncRoutine(preferredAccountsFilter, model::setPreferredAccountsFilter);
-
-        insecureWarningText = context.findViewById(R.id.insecure_scheme_text);
+        hookTextChangeSyncRoutine(binding.preferredAccountsFilter,
+                model::setPreferredAccountsFilter);
 
-        hookClearErrorOnFocusListener(profileName, profileNameLayout);
-        hookClearErrorOnFocusListener(url, urlLayout);
-        hookClearErrorOnFocusListener(userName, userNameLayout);
-        hookClearErrorOnFocusListener(password, passwordLayout);
+        hookClearErrorOnFocusListener(binding.profileName, binding.profileNameLayout);
+        hookClearErrorOnFocusListener(binding.url, binding.urlLayout);
+        hookClearErrorOnFocusListener(binding.authUserName, binding.authUserNameLayout);
+        hookClearErrorOnFocusListener(binding.password, binding.passwordLayout);
 
-        if (savedInstanceState == null) {
-            model.setValuesFromProfile(mProfile, getArguments().getInt(ARG_HUE, -1));
-        }
-        checkInsecureSchemeWithAuth();
-
-        url.addTextChangedListener(new TextWatcher() {
+        binding.url.addTextChangedListener(new TextWatcher() {
             @Override
             public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
             @Override
@@ -370,14 +320,14 @@ public class ProfileDetailFragment extends Fragment {
             }
         });
 
-        huePickerView.setOnClickListener(v -> {
+        binding.btnPickRingColor.setOnClickListener(v -> {
             HueRingDialog d = new HueRingDialog(ProfileDetailFragment.this.requireContext(),
                     model.initialThemeHue, (Integer) v.getTag());
             d.show();
             d.setColorSelectedListener(model::setThemeId);
         });
 
-        profileName.requestFocus();
+        binding.profileName.requestFocus();
     }
     private void chooseAPIVersion(View v) {
         Activity context = getActivity();
@@ -386,48 +336,58 @@ public class ProfileDetailFragment extends Fragment {
         PopupMenu menu = new PopupMenu(context, v);
         menu.inflate(R.menu.api_version);
         menu.setOnMenuItemClickListener(item -> {
-            SendTransactionTask.API apiVer;
-            switch (item.getItemId()) {
-                case R.id.api_version_menu_html:
-                    apiVer = SendTransactionTask.API.html;
-                    break;
-                case R.id.api_version_menu_post_1_14:
-                    apiVer = SendTransactionTask.API.post_1_14;
-                    break;
-                case R.id.api_version_menu_pre_1_15:
-                    apiVer = SendTransactionTask.API.pre_1_15;
-                    break;
-                case R.id.api_version_menu_auto:
-                default:
-                    apiVer = SendTransactionTask.API.auto;
+            API apiVer;
+            int itemId = item.getItemId();
+            if (itemId == R.id.api_version_menu_html) {
+                apiVer = API.html;
+            }
+            else if (itemId == R.id.api_version_menu_1_23) {
+                apiVer = API.v1_23;
+            }
+            else if (itemId == R.id.api_version_menu_1_19_1) {
+                apiVer = API.v1_19_1;
+            }
+            else if (itemId == R.id.api_version_menu_1_15) {
+                apiVer = API.v1_15;
+            }
+            else if (itemId == R.id.api_version_menu_1_14) {
+                apiVer = API.v1_14;
+            }
+            else {
+                apiVer = API.auto;
             }
             model.setApiVersion(apiVer);
-            apiVersionText.setText(apiVer.getDescription(getResources()));
+            binding.apiVersionText.setText(apiVer.getDescription(getResources()));
             return true;
         });
         menu.show();
     }
-    private MobileLedgerProfile.FutureDates futureDatesSettingFromMenuItemId(int itemId) {
-        switch (itemId) {
-            case R.id.menu_future_dates_7:
-                return MobileLedgerProfile.FutureDates.OneWeek;
-            case R.id.menu_future_dates_14:
-                return MobileLedgerProfile.FutureDates.TwoWeeks;
-            case R.id.menu_future_dates_30:
-                return MobileLedgerProfile.FutureDates.OneMonth;
-            case R.id.menu_future_dates_60:
-                return MobileLedgerProfile.FutureDates.TwoMonths;
-            case R.id.menu_future_dates_90:
-                return MobileLedgerProfile.FutureDates.ThreeMonths;
-            case R.id.menu_future_dates_180:
-                return MobileLedgerProfile.FutureDates.SixMonths;
-            case R.id.menu_future_dates_365:
-                return MobileLedgerProfile.FutureDates.OneYear;
-            case R.id.menu_future_dates_all:
-                return MobileLedgerProfile.FutureDates.All;
-            default:
-                return MobileLedgerProfile.FutureDates.None;
+    private FutureDates futureDatesSettingFromMenuItemId(int itemId) {
+        if (itemId == R.id.menu_future_dates_7) {
+            return FutureDates.OneWeek;
+        }
+        else if (itemId == R.id.menu_future_dates_14) {
+            return FutureDates.TwoWeeks;
+        }
+        else if (itemId == R.id.menu_future_dates_30) {
+            return FutureDates.OneMonth;
+        }
+        else if (itemId == R.id.menu_future_dates_60) {
+            return FutureDates.TwoMonths;
         }
+        else if (itemId == R.id.menu_future_dates_90) {
+            return FutureDates.ThreeMonths;
+        }
+        else if (itemId == R.id.menu_future_dates_180) {
+            return FutureDates.SixMonths;
+        }
+        else if (itemId == R.id.menu_future_dates_365) {
+            return FutureDates.OneYear;
+        }
+        else if (itemId == R.id.menu_future_dates_all) {
+            return FutureDates.All;
+        }
+        return FutureDates.None;
     }
     @NotNull
     private ProfileDetailModel getModel() {
@@ -438,44 +398,22 @@ public class ProfileDetailFragment extends Fragment {
             return;
 
         ProfileDetailModel model = getModel();
-        final ArrayList<MobileLedgerProfile> profiles =
-                Objects.requireNonNull(Data.profiles.getValue());
-
-        if (mProfile != null) {
-            int pos = Data.profiles.getValue()
-                                   .indexOf(mProfile);
-            mProfile = new MobileLedgerProfile(mProfile);
-            model.updateProfile(mProfile);
-            mProfile.storeInDB();
+        ProfileDAO dao = DB.get()
+                           .getProfileDAO();
+
+        Profile profile = new Profile();
+        model.updateProfile(profile);
+        if (profile.getId() > 0) {
+            dao.update(profile);
             debug("profiles", "profile stored in DB");
-            profiles.set(pos, mProfile);
 //                debug("profiles", String.format("Selected item is %d", mProfile.getThemeHue()));
-
-            final MobileLedgerProfile currentProfile = Data.getProfile();
-            if (mProfile.getUuid()
-                        .equals(currentProfile.getUuid()))
-            {
-                Data.setCurrentProfile(mProfile);
-            }
-
-            ProfilesRecyclerViewAdapter viewAdapter = ProfilesRecyclerViewAdapter.getInstance();
-            if (viewAdapter != null)
-                viewAdapter.notifyItemChanged(pos);
         }
         else {
-            mProfile = new MobileLedgerProfile(String.valueOf(UUID.randomUUID()));
-            model.updateProfile(mProfile);
-            mProfile.storeInDB();
-            final ArrayList<MobileLedgerProfile> newList = new ArrayList<>(profiles);
-            newList.add(mProfile);
-            Data.profiles.setValue(newList);
-            MobileLedgerProfile.storeProfilesOrder();
-
-            // first profile ever?
-            if (newList.size() == 1)
-                Data.setCurrentProfile(mProfile);
+            dao.insertLast(profile, null);
         }
 
+        BackupManager.dataChanged(BuildConfig.APPLICATION_ID);
+
         Activity activity = getActivity();
         if (activity != null)
             activity.finish();
@@ -489,7 +427,7 @@ public class ProfileDetailFragment extends Fragment {
                           .trim();
         if (val.isEmpty()) {
             valid = false;
-            urlLayout.setError(getResources().getText(R.string.err_profile_url_empty));
+            binding.urlLayout.setError(getResources().getText(R.string.err_profile_url_empty));
         }
         try {
             URL url = new URL(val);
@@ -500,12 +438,12 @@ public class ProfileDetailFragment extends Fragment {
                                  .toUpperCase();
             if (!protocol.equals("HTTP") && !protocol.equals("HTTPS")) {
                 valid = false;
-                urlLayout.setError(getResources().getText(R.string.err_invalid_url));
+                binding.urlLayout.setError(getResources().getText(R.string.err_invalid_url));
             }
         }
         catch (MalformedURLException e) {
             valid = false;
-            urlLayout.setError(getResources().getText(R.string.err_invalid_url));
+            binding.urlLayout.setError(getResources().getText(R.string.err_invalid_url));
         }
 
         return valid;
@@ -517,14 +455,15 @@ public class ProfileDetailFragment extends Fragment {
 
         if (model.getUseAuthentication()) {
             String urlText = model.getUrl();
-            if (urlText.startsWith("http") && !urlText.startsWith("https"))
+            if (urlText.startsWith("http://") ||
+                urlText.length() >= 8 && !urlText.startsWith("https://"))
                 showWarning = true;
         }
 
         if (showWarning)
-            insecureWarningText.setVisibility(View.VISIBLE);
+            binding.insecureSchemeText.setVisibility(View.VISIBLE);
         else
-            insecureWarningText.setVisibility(View.GONE);
+            binding.insecureSchemeText.setVisibility(View.GONE);
     }
     private void hookClearErrorOnFocusListener(TextView view, TextInputLayout layout) {
         view.setOnFocusChangeListener((v, hasFocus) -> {
@@ -553,11 +492,11 @@ public class ProfileDetailFragment extends Fragment {
         try {
             ProfileDetailModel model = getModel();
 
-            model.setProfileName(profileName.getText());
-            model.setUrl(url.getText());
-            model.setPreferredAccountsFilter(preferredAccountsFilter.getText());
-            model.setAuthUserName(userName.getText());
-            model.setAuthPassword(password.getText());
+            model.setProfileName(binding.profileName.getText());
+            model.setUrl(binding.url.getText());
+            model.setPreferredAccountsFilter(binding.preferredAccountsFilter.getText());
+            model.setAuthUserName(binding.authUserName.getText());
+            model.setAuthPassword(binding.password.getText());
         }
         finally {
             syncingModelFromUI = false;
@@ -566,33 +505,34 @@ public class ProfileDetailFragment extends Fragment {
     private boolean checkValidity() {
         boolean valid = true;
 
-        String val = String.valueOf(profileName.getText());
+        String val = String.valueOf(binding.profileName.getText());
         if (val.trim()
                .isEmpty())
         {
             valid = false;
-            profileNameLayout.setError(getResources().getText(R.string.err_profile_name_empty));
+            binding.profileNameLayout.setError(
+                    getResources().getText(R.string.err_profile_name_empty));
         }
 
         if (!checkUrlValidity())
             valid = false;
 
-        if (useAuthentication.isChecked()) {
-            val = String.valueOf(userName.getText());
+        if (binding.enableHttpAuth.isChecked()) {
+            val = String.valueOf(binding.authUserName.getText());
             if (val.trim()
                    .isEmpty())
             {
                 valid = false;
-                userNameLayout.setError(
+                binding.authUserNameLayout.setError(
                         getResources().getText(R.string.err_profile_user_name_empty));
             }
 
-            val = String.valueOf(password.getText());
+            val = String.valueOf(binding.password.getText());
             if (val.trim()
                    .isEmpty())
             {
                 valid = false;
-                passwordLayout.setError(
+                binding.passwordLayout.setError(
                         getResources().getText(R.string.err_profile_password_empty));
             }
         }
@@ -601,13 +541,14 @@ public class ProfileDetailFragment extends Fragment {
     }
     private void resetDefaultCommodity() {
         defaultCommoditySet = false;
-        defaultCommodity.setText(R.string.btn_no_currency);
-        defaultCommodity.setTypeface(defaultCommodity.getTypeface(), Typeface.ITALIC);
+        binding.defaultCommodityText.setText(R.string.btn_no_currency);
+        binding.defaultCommodityText.setTypeface(binding.defaultCommodityText.getTypeface(),
+                Typeface.ITALIC);
     }
     private void setDefaultCommodity(@NonNull @NotNull String name) {
         defaultCommoditySet = true;
-        defaultCommodity.setText(name);
-        defaultCommodity.setTypeface(Typeface.DEFAULT);
+        binding.defaultCommodityText.setText(name);
+        binding.defaultCommodityText.setTypeface(Typeface.DEFAULT);
     }
     interface TextChangeSyncRoutine {
         void onTextChanged(String text);
index 92d1505fb0baa159bd38cf2e24b9f003d2ddfa4a..09b0a067a66256b30565fa90191b870505445915 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2020 Damyan Ivanov.
+ * Copyright © 2021 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
@@ -20,14 +20,16 @@ package net.ktnx.mobileledger.ui.profiles;
 import android.text.TextUtils;
 
 import androidx.lifecycle.LifecycleOwner;
+import androidx.lifecycle.LiveData;
 import androidx.lifecycle.MutableLiveData;
 import androidx.lifecycle.Observer;
 import androidx.lifecycle.ViewModel;
 
-import net.ktnx.mobileledger.async.SendTransactionTask;
-import net.ktnx.mobileledger.model.Currency;
+import net.ktnx.mobileledger.App;
+import net.ktnx.mobileledger.db.Profile;
+import net.ktnx.mobileledger.json.API;
+import net.ktnx.mobileledger.model.FutureDates;
 import net.ktnx.mobileledger.model.HledgerVersion;
-import net.ktnx.mobileledger.model.MobileLedgerProfile;
 import net.ktnx.mobileledger.utils.Colors;
 import net.ktnx.mobileledger.utils.Logger;
 import net.ktnx.mobileledger.utils.Misc;
@@ -43,24 +45,28 @@ import java.util.Objects;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
+import static net.ktnx.mobileledger.db.Profile.NO_PROFILE_ID;
+
 public class ProfileDetailModel extends ViewModel {
     private static final String HTTPS_URL_START = "https://";
     private final MutableLiveData<String> profileName = new MutableLiveData<>();
+    private final MutableLiveData<Integer> orderNo = new MutableLiveData<>();
     private final MutableLiveData<Boolean> postingPermitted = new MutableLiveData<>(true);
-    private final MutableLiveData<Currency> defaultCommodity = new MutableLiveData<>(null);
-    private final MutableLiveData<MobileLedgerProfile.FutureDates> futureDates =
-            new MutableLiveData<>(MobileLedgerProfile.FutureDates.None);
+    private final MutableLiveData<String> defaultCommodity = new MutableLiveData<>(null);
+    private final MutableLiveData<FutureDates> futureDates =
+            new MutableLiveData<>(FutureDates.None);
     private final MutableLiveData<Boolean> showCommodityByDefault = new MutableLiveData<>(false);
     private final MutableLiveData<Boolean> showCommentsByDefault = new MutableLiveData<>(true);
     private final MutableLiveData<Boolean> useAuthentication = new MutableLiveData<>(false);
-    private final MutableLiveData<SendTransactionTask.API> apiVersion =
-            new MutableLiveData<>(SendTransactionTask.API.auto);
+    private final MutableLiveData<API> apiVersion = new MutableLiveData<>(API.auto);
     private final MutableLiveData<String> url = new MutableLiveData<>(null);
     private final MutableLiveData<String> authUserName = new MutableLiveData<>(null);
     private final MutableLiveData<String> authPassword = new MutableLiveData<>(null);
     private final MutableLiveData<String> preferredAccountsFilter = new MutableLiveData<>(null);
     private final MutableLiveData<Integer> themeId = new MutableLiveData<>(-1);
     private final MutableLiveData<HledgerVersion> detectedVersion = new MutableLiveData<>(null);
+    private final MutableLiveData<Boolean> detectingHledgerVersion = new MutableLiveData<>(false);
+    private final MutableLiveData<Long> profileId = new MutableLiveData<>(NO_PROFILE_ID);
     public int initialThemeHue = Colors.DEFAULT_HUE_DEG;
     private VersionDetectionThread versionDetectionThread;
     public ProfileDetailModel() {
@@ -96,24 +102,24 @@ public class ProfileDetailModel extends ViewModel {
     void observeShowCommentsByDefault(LifecycleOwner lfo, Observer<Boolean> o) {
         showCommentsByDefault.observe(lfo, o);
     }
-    MobileLedgerProfile.FutureDates getFutureDates() {
+    FutureDates getFutureDates() {
         return futureDates.getValue();
     }
-    void setFutureDates(MobileLedgerProfile.FutureDates newValue) {
+    void setFutureDates(FutureDates newValue) {
         if (newValue != futureDates.getValue())
             futureDates.setValue(newValue);
     }
-    void observeFutureDates(LifecycleOwner lfo, Observer<MobileLedgerProfile.FutureDates> o) {
+    void observeFutureDates(LifecycleOwner lfo, Observer<FutureDates> o) {
         futureDates.observe(lfo, o);
     }
-    Currency getDefaultCommodity() {
+    String getDefaultCommodity() {
         return defaultCommodity.getValue();
     }
-    void setDefaultCommodity(Currency newValue) {
-        if (newValue != defaultCommodity.getValue())
+    void setDefaultCommodity(String newValue) {
+        if (!Misc.equalStrings(newValue, defaultCommodity.getValue()))
             defaultCommodity.setValue(newValue);
     }
-    void observeDefaultCommodity(LifecycleOwner lfo, Observer<Currency> o) {
+    void observeDefaultCommodity(LifecycleOwner lfo, Observer<String> o) {
         defaultCommodity.observe(lfo, o);
     }
     Boolean getShowCommodityByDefault() {
@@ -126,7 +132,7 @@ public class ProfileDetailModel extends ViewModel {
     void observeShowCommodityByDefault(LifecycleOwner lfo, Observer<Boolean> o) {
         showCommodityByDefault.observe(lfo, o);
     }
-    Boolean getUseAuthentication() {
+    public Boolean getUseAuthentication() {
         return useAuthentication.getValue();
     }
     void setUseAuthentication(boolean newValue) {
@@ -136,14 +142,14 @@ public class ProfileDetailModel extends ViewModel {
     void observeUseAuthentication(LifecycleOwner lfo, Observer<Boolean> o) {
         useAuthentication.observe(lfo, o);
     }
-    SendTransactionTask.API getApiVersion() {
+    API getApiVersion() {
         return apiVersion.getValue();
     }
-    void setApiVersion(SendTransactionTask.API newValue) {
+    void setApiVersion(API newValue) {
         if (newValue != apiVersion.getValue())
             apiVersion.setValue(newValue);
     }
-    void observeApiVersion(LifecycleOwner lfo, Observer<SendTransactionTask.API> o) {
+    void observeApiVersion(LifecycleOwner lfo, Observer<API> o) {
         apiVersion.observe(lfo, o);
     }
     HledgerVersion getDetectedVersion() { return detectedVersion.getValue(); }
@@ -154,7 +160,7 @@ public class ProfileDetailModel extends ViewModel {
     void observeDetectedVersion(LifecycleOwner lfo, Observer<HledgerVersion> o) {
         detectedVersion.observe(lfo, o);
     }
-    String getUrl() {
+    public String getUrl() {
         return url.getValue();
     }
     void setUrl(String newValue) {
@@ -168,7 +174,7 @@ public class ProfileDetailModel extends ViewModel {
     void observeUrl(LifecycleOwner lfo, Observer<String> o) {
         url.observe(lfo, o);
     }
-    String getAuthUserName() {
+    public String getAuthUserName() {
         return authUserName.getValue();
     }
     void setAuthUserName(String newValue) {
@@ -182,7 +188,7 @@ public class ProfileDetailModel extends ViewModel {
     void observeUserName(LifecycleOwner lfo, Observer<String> o) {
         authUserName.observe(lfo, o);
     }
-    String getAuthPassword() {
+    public String getAuthPassword() {
         return authPassword.getValue();
     }
     void setAuthPassword(String newValue) {
@@ -219,11 +225,15 @@ public class ProfileDetailModel extends ViewModel {
     void observeThemeId(LifecycleOwner lfo, Observer<Integer> o) {
         themeId.observe(lfo, o);
     }
-    void setValuesFromProfile(MobileLedgerProfile mProfile, int newProfileHue) {
-        final int profileThemeId;
+    void observeDetectingHledgerVersion(LifecycleOwner lfo, Observer<Boolean> o) {
+        detectingHledgerVersion.observe(lfo, o);
+    }
+    void setValuesFromProfile(Profile mProfile) {
         if (mProfile != null) {
+            profileId.setValue(mProfile.getId());
             profileName.setValue(mProfile.getName());
-            postingPermitted.setValue(mProfile.isPostingPermitted());
+            orderNo.setValue(mProfile.getOrderNo());
+            postingPermitted.setValue(mProfile.permitPosting());
             showCommentsByDefault.setValue(mProfile.getShowCommentsByDefault());
             showCommodityByDefault.setValue(mProfile.getShowCommodityByDefault());
             {
@@ -231,50 +241,60 @@ public class ProfileDetailModel extends ViewModel {
                 if (TextUtils.isEmpty(comm))
                     setDefaultCommodity(null);
                 else
-                    setDefaultCommodity(new Currency(-1, comm));
+                    setDefaultCommodity(comm);
             }
-            futureDates.setValue(mProfile.getFutureDates());
-            apiVersion.setValue(mProfile.getApiVersion());
+            futureDates.setValue(FutureDates.valueOf(mProfile.getFutureDates()));
+            apiVersion.setValue(API.valueOf(mProfile.getApiVersion()));
             url.setValue(mProfile.getUrl());
-            useAuthentication.setValue(mProfile.isAuthEnabled());
-            authUserName.setValue(mProfile.isAuthEnabled() ? mProfile.getAuthUserName() : "");
-            authPassword.setValue(mProfile.isAuthEnabled() ? mProfile.getAuthPassword() : "");
+            useAuthentication.setValue(mProfile.useAuthentication());
+            authUserName.setValue(mProfile.useAuthentication() ? mProfile.getAuthUser() : "");
+            authPassword.setValue(mProfile.useAuthentication() ? mProfile.getAuthPassword() : "");
             preferredAccountsFilter.setValue(mProfile.getPreferredAccountsFilter());
-            themeId.setValue(mProfile.getThemeHue());
-            detectedVersion.setValue(mProfile.getDetectedVersion());
+            themeId.setValue(mProfile.getTheme());
+            detectedVersion.setValue(mProfile.detectedVersionPre_1_19() ? new HledgerVersion(true)
+                                                                        : new HledgerVersion(
+                                                                                mProfile.getDetectedVersionMajor(),
+                                                                                mProfile.getDetectedVersionMinor()));
         }
         else {
+            profileId.setValue(NO_PROFILE_ID);
+            orderNo.setValue(-1);
             profileName.setValue(null);
             url.setValue(HTTPS_URL_START);
             postingPermitted.setValue(true);
             showCommentsByDefault.setValue(true);
             showCommodityByDefault.setValue(false);
-            setFutureDates(MobileLedgerProfile.FutureDates.None);
-            setApiVersion(SendTransactionTask.API.auto);
+            setFutureDates(FutureDates.None);
+            setApiVersion(API.auto);
             useAuthentication.setValue(false);
             authUserName.setValue("");
             authPassword.setValue("");
             preferredAccountsFilter.setValue(null);
-            themeId.setValue(newProfileHue);
             detectedVersion.setValue(null);
         }
     }
-    void updateProfile(MobileLedgerProfile mProfile) {
+    void updateProfile(Profile mProfile) {
+        mProfile.setId(profileId.getValue());
         mProfile.setName(profileName.getValue());
+        mProfile.setOrderNo(orderNo.getValue());
         mProfile.setUrl(url.getValue());
-        mProfile.setPostingPermitted(postingPermitted.getValue());
+        mProfile.setPermitPosting(postingPermitted.getValue());
         mProfile.setShowCommentsByDefault(showCommentsByDefault.getValue());
-        Currency c = defaultCommodity.getValue();
-        mProfile.setDefaultCommodity((c == null) ? null : c.getName());
+        mProfile.setDefaultCommodity(defaultCommodity.getValue());
         mProfile.setShowCommodityByDefault(showCommodityByDefault.getValue());
         mProfile.setPreferredAccountsFilter(preferredAccountsFilter.getValue());
-        mProfile.setAuthEnabled(useAuthentication.getValue());
-        mProfile.setAuthUserName(authUserName.getValue());
+        mProfile.setUseAuthentication(useAuthentication.getValue());
+        mProfile.setAuthUser(authUserName.getValue());
         mProfile.setAuthPassword(authPassword.getValue());
-        mProfile.setThemeHue(themeId.getValue());
-        mProfile.setFutureDates(futureDates.getValue());
-        mProfile.setApiVersion(apiVersion.getValue());
-        mProfile.setDetectedVersion(detectedVersion.getValue());
+        mProfile.setTheme(themeId.getValue());
+        mProfile.setFutureDates(futureDates.getValue()
+                                           .toInt());
+        mProfile.setApiVersion(apiVersion.getValue()
+                                         .toInt());
+        HledgerVersion version = detectedVersion.getValue();
+        mProfile.setDetectedVersionPre_1_19(version != null && version.isPre_1_20_1());
+        mProfile.setDetectedVersionMajor(version != null ? version.getMajor() : -1);
+        mProfile.setDetectedVersionMinor(version != null ? version.getMinor() : -1);
     }
     synchronized public void triggerVersionDetection() {
         if (versionDetectionThread != null)
@@ -283,30 +303,33 @@ public class ProfileDetailModel extends ViewModel {
         versionDetectionThread = new VersionDetectionThread(this);
         versionDetectionThread.start();
     }
+    public LiveData<Long> getProfileId() {
+        return profileId;
+    }
     static class VersionDetectionThread extends Thread {
+        static final int TARGET_PROCESS_DURATION = 1000;
         private final Pattern versionPattern =
                 Pattern.compile("^\"(\\d+)\\.(\\d+)(?:\\.(\\d+))?\"$");
         private final ProfileDetailModel model;
         public VersionDetectionThread(ProfileDetailModel model) {
             this.model = model;
         }
-        @Override
-        public void run() {
+        private HledgerVersion detectVersion() {
+            App.setAuthenticationDataFromProfileModel(model);
+            HttpURLConnection http;
             try {
-                HttpURLConnection http = NetworkUtil.prepareConnection(model.getUrl(), "version",
+                http = NetworkUtil.prepareConnection(model.getUrl(), "version",
                         model.getUseAuthentication());
                 switch (http.getResponseCode()) {
                     case 200:
                         break;
                     case 404:
-                        model.detectedVersion.postValue(new HledgerVersion(true));
-                        return;
+                        return new HledgerVersion(true);
                     default:
                         Logger.debug("profile", String.format(Locale.US,
                                 "HTTP error detecting hledger-web version: [%d] %s",
                                 http.getResponseCode(), http.getResponseMessage()));
-                        model.detectedVersion.postValue(null);
-                        return;
+                        return null;
                 }
                 InputStream stream = http.getInputStream();
                 BufferedReader reader = new BufferedReader(new InputStreamReader(stream));
@@ -315,20 +338,49 @@ public class ProfileDetailModel extends ViewModel {
                 if (m.matches()) {
                     int major = Integer.parseInt(Objects.requireNonNull(m.group(1)));
                     int minor = Integer.parseInt(Objects.requireNonNull(m.group(2)));
-                    final boolean hasPatch = m.groupCount() >= 3;
-                    int patch = hasPatch ? Integer.parseInt(Objects.requireNonNull(m.group(3))) : 0;
+                    final String patchText = m.group(3);
+                    final boolean hasPatch = patchText != null;
+                    int patch = hasPatch ? Integer.parseInt(patchText) : 0;
 
-                    model.detectedVersion.postValue(
-                            hasPatch ? new HledgerVersion(major, minor, patch)
-                                     : new HledgerVersion(major, minor));
+                    return hasPatch ? new HledgerVersion(major, minor, patch)
+                                    : new HledgerVersion(major, minor);
                 }
                 else {
                     Logger.debug("profile",
                             String.format("Unrecognised version string '%s'", version));
+                    return null;
                 }
             }
             catch (IOException e) {
                 e.printStackTrace();
+                return null;
+            }
+            finally {
+                App.resetAuthenticationData();
+            }
+        }
+        @Override
+        public void run() {
+            model.detectingHledgerVersion.postValue(true);
+            try {
+                long startTime = System.currentTimeMillis();
+
+                final HledgerVersion version = detectVersion();
+
+                long elapsed = System.currentTimeMillis() - startTime;
+                Logger.debug("profile", "Detection duration " + elapsed);
+                if (elapsed < TARGET_PROCESS_DURATION) {
+                    try {
+                        Thread.sleep(TARGET_PROCESS_DURATION - elapsed);
+                    }
+                    catch (InterruptedException e) {
+                        e.printStackTrace();
+                    }
+                }
+                model.detectedVersion.postValue(version);
+            }
+            finally {
+                model.detectingHledgerVersion.postValue(false);
             }
         }
     }
index cef8d8fbf6803e71c08491bf4d5805b121473441..08a071ddf63106703fbd10c00c501c6065b22599 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2020 Damyan Ivanov.
+ * Copyright © 2021 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
@@ -17,8 +17,6 @@
 
 package net.ktnx.mobileledger.ui.profiles;
 
-import android.content.Context;
-import android.content.Intent;
 import android.graphics.drawable.ColorDrawable;
 import android.view.LayoutInflater;
 import android.view.View;
@@ -30,39 +28,48 @@ import android.widget.LinearLayout;
 import android.widget.TextView;
 
 import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.constraintlayout.widget.ConstraintLayout;
 import androidx.lifecycle.MutableLiveData;
+import androidx.recyclerview.widget.AsyncListDiffer;
+import androidx.recyclerview.widget.DiffUtil;
 import androidx.recyclerview.widget.ItemTouchHelper;
 import androidx.recyclerview.widget.RecyclerView;
 
 import net.ktnx.mobileledger.R;
+import net.ktnx.mobileledger.db.DB;
+import net.ktnx.mobileledger.db.Profile;
 import net.ktnx.mobileledger.model.Data;
-import net.ktnx.mobileledger.model.MobileLedgerProfile;
-import net.ktnx.mobileledger.ui.activity.ProfileDetailActivity;
 import net.ktnx.mobileledger.utils.Colors;
 
-import java.lang.ref.WeakReference;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.List;
 
 import static net.ktnx.mobileledger.utils.Logger.debug;
 
 public class ProfilesRecyclerViewAdapter
         extends RecyclerView.Adapter<ProfilesRecyclerViewAdapter.ProfileListViewHolder> {
-    private static WeakReference<ProfilesRecyclerViewAdapter> instanceRef;
     public final MutableLiveData<Boolean> editingProfiles = new MutableLiveData<>(false);
-    private final View.OnClickListener mOnClickListener = view -> {
-        MobileLedgerProfile profile = (MobileLedgerProfile) ((View) view.getParent()).getTag();
-        editProfile(view, profile);
-    };
     private final ItemTouchHelper rearrangeHelper;
+    private final AsyncListDiffer<Profile> listDiffer;
     private RecyclerView recyclerView;
     private boolean animationsEnabled = true;
+
     public ProfilesRecyclerViewAdapter() {
-        instanceRef = new WeakReference<>(this);
         debug("flow", "ProfilesRecyclerViewAdapter.new()");
 
+        setHasStableIds(true);
+        listDiffer = new AsyncListDiffer<>(this, new DiffUtil.ItemCallback<Profile>() {
+            @Override
+            public boolean areItemsTheSame(@NonNull Profile oldItem, @NonNull Profile newItem) {
+                return oldItem.getId() == newItem.getId();
+            }
+            @Override
+            public boolean areContentsTheSame(@NonNull Profile oldItem, @NonNull Profile newItem) {
+                return oldItem.equals(newItem);
+            }
+        });
+
         ItemTouchHelper.Callback cb = new ItemTouchHelper.Callback() {
             @Override
             public int getMovementFlags(@NonNull RecyclerView recyclerView,
@@ -73,13 +80,14 @@ public class ProfilesRecyclerViewAdapter
             public boolean onMove(@NonNull RecyclerView recyclerView,
                                   @NonNull RecyclerView.ViewHolder viewHolder,
                                   @NonNull RecyclerView.ViewHolder target) {
-                final ArrayList<MobileLedgerProfile> profiles = Data.profiles.getValue();
-                if (profiles == null)
-                    throw new AssertionError();
-                Collections.swap(profiles, viewHolder.getAdapterPosition(),
-                        target.getAdapterPosition());
-                MobileLedgerProfile.storeProfilesOrder();
-                notifyItemMoved(viewHolder.getAdapterPosition(), target.getAdapterPosition());
+                final List<Profile> profiles = new ArrayList<>(listDiffer.getCurrentList());
+                Collections.swap(profiles, viewHolder.getBindingAdapterPosition(),
+                        target.getBindingAdapterPosition());
+                DB.get()
+                  .getProfileDAO()
+                  .updateOrder(profiles, null);
+//                notifyItemMoved(viewHolder.getBindingAdapterPosition(), target
+//                .getBindingAdapterPosition());
                 return true;
             }
             @Override
@@ -88,9 +96,14 @@ public class ProfilesRecyclerViewAdapter
         };
         rearrangeHelper = new ItemTouchHelper(cb);
     }
-    public static @Nullable
-    ProfilesRecyclerViewAdapter getInstance() {
-        return instanceRef.get();
+    @Override
+    public long getItemId(int position) {
+        return listDiffer.getCurrentList()
+                         .get(position)
+                         .getId();
+    }
+    public void setProfileList(List<Profile> list) {
+        listDiffer.submitList(list);
     }
     public void setAnimationsEnabled(boolean animationsEnabled) {
         this.animationsEnabled = animationsEnabled;
@@ -132,72 +145,22 @@ public class ProfilesRecyclerViewAdapter
         else
             startEditingProfiles();
     }
-    private void editProfile(View view, MobileLedgerProfile profile) {
-        int index = Data.getProfileIndex(profile);
-        Context context = view.getContext();
-        Intent intent = new Intent(context, ProfileDetailActivity.class);
-        intent.addFlags(Intent.FLAG_ACTIVITY_NO_USER_ACTION);
-        if (index != -1)
-            intent.putExtra(ProfileDetailFragment.ARG_ITEM_ID, index);
-
-        context.startActivity(intent);
-    }
-    private void onProfileRowClicked(View v) {
-        if (editingProfiles())
-            return;
-        MobileLedgerProfile profile = (MobileLedgerProfile) v.getTag();
-        if (profile == null)
-            throw new IllegalStateException("Profile row without associated profile");
-        debug("profiles", "Setting profile to " + profile.getName());
-        if (Data.getProfile() != profile)
-            Data.drawerOpen.setValue(false);
-        Data.setCurrentProfile(profile);
-    }
     @NonNull
     @Override
     public ProfileListViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
         View view = LayoutInflater.from(parent.getContext())
                                   .inflate(R.layout.profile_list_content, parent, false);
-        ProfileListViewHolder holder = new ProfileListViewHolder(view);
-
-        holder.mRow.setOnClickListener(this::onProfileRowClicked);
-        holder.mTitle.setOnClickListener(v -> {
-            View row = (View) v.getParent();
-            onProfileRowClicked(row);
-        });
-        holder.mColorTag.setOnClickListener(v -> {
-            View row = (View) v.getParent()
-                               .getParent();
-            onProfileRowClicked(row);
-        });
-        holder.mTitle.setOnLongClickListener(v -> {
-            flipEditingProfiles();
-            return true;
-        });
-
-        View.OnTouchListener dragStarter = (v, event) -> {
-            if (rearrangeHelper != null && editingProfiles()) {
-                rearrangeHelper.startDrag(holder);
-                return true;
-            }
-            return false;
-        };
-
-        holder.tagAndHandleLayout.setOnTouchListener(dragStarter);
-        return holder;
+        return new ProfileListViewHolder(view);
     }
     @Override
     public void onBindViewHolder(@NonNull final ProfileListViewHolder holder, int position) {
-        final ArrayList<MobileLedgerProfile> profiles = Data.profiles.getValue();
-        if (profiles == null)
-            throw new AssertionError();
-        final MobileLedgerProfile profile = profiles.get(position);
-        final MobileLedgerProfile currentProfile = Data.getProfile();
+        final Profile profile = listDiffer.getCurrentList()
+                                          .get(position);
+        final Profile currentProfile = Data.getProfile();
 //        debug("profiles", String.format(Locale.ENGLISH, "pos %d: %s, current: %s", position,
 //                profile.getUuid(), currentProfile.getUuid()));
-        holder.itemView.setTag(profile);
 
-        int hue = profile.getThemeHue();
+        int hue = profile.getTheme();
         if (hue == -1)
             holder.mColorTag.setBackgroundColor(
                     Colors.getPrimaryColorForHue(Colors.DEFAULT_HUE_DEG));
@@ -207,9 +170,14 @@ public class ProfilesRecyclerViewAdapter
         holder.mTitle.setText(profile.getName());
 //            holder.mSubTitle.setText(profile.getUrl());
 
-        holder.mEditButton.setOnClickListener(mOnClickListener);
+        holder.mEditButton.setOnClickListener(view -> {
+            Profile p = listDiffer.getCurrentList()
+                                  .get(holder.getBindingAdapterPosition());
+            ProfileDetailActivity.start(view.getContext(), p);
+        });
 
-        final boolean sameProfile = currentProfile.equals(profile);
+        final boolean sameProfile =
+                currentProfile != null && currentProfile.getId() == profile.getId();
         holder.itemView.setBackground(
                 sameProfile ? new ColorDrawable(Colors.tableRowDarkBG) : null);
         if (editingProfiles()) {
@@ -237,10 +205,10 @@ public class ProfilesRecyclerViewAdapter
     }
     @Override
     public int getItemCount() {
-        final ArrayList<MobileLedgerProfile> profiles = Data.profiles.getValue();
-        return profiles != null ? profiles.size() : 0;
+        return listDiffer.getCurrentList()
+                         .size();
     }
-    static class ProfileListViewHolder extends RecyclerView.ViewHolder {
+    class ProfileListViewHolder extends RecyclerView.ViewHolder {
         final TextView mEditButton;
         final TextView mTitle, mColorTag;
         final LinearLayout tagAndHandleLayout;
@@ -255,6 +223,47 @@ public class ProfilesRecyclerViewAdapter
             mRearrangeHandle = view.findViewById(R.id.profile_list_rearrange_handle);
             tagAndHandleLayout = view.findViewById(R.id.handle_and_tag);
             mRow = (ConstraintLayout) view;
+
+
+            mRow.setOnClickListener(this::onProfileRowClicked);
+            mTitle.setOnClickListener(v -> {
+                View row = (View) v.getParent();
+                onProfileRowClicked(row);
+            });
+            mColorTag.setOnClickListener(v -> {
+                View row = (View) v.getParent()
+                                   .getParent();
+                onProfileRowClicked(row);
+            });
+            mTitle.setOnLongClickListener(v -> {
+                flipEditingProfiles();
+                return true;
+            });
+
+            View.OnTouchListener dragStarter = (v, event) -> {
+                if (rearrangeHelper != null && editingProfiles()) {
+                    rearrangeHelper.startDrag(this);
+                    return true;
+                }
+                return false;
+            };
+
+            tagAndHandleLayout.setOnTouchListener(dragStarter);
+        }
+        private void onProfileRowClicked(View v) {
+            if (editingProfiles())
+                return;
+            Profile profile = listDiffer.getCurrentList()
+                                        .get(getBindingAdapterPosition());
+            if (Data.getProfile() != profile) {
+                debug("profiles", "Setting profile to " + profile.getName());
+                Data.drawerOpen.setValue(false);
+                Data.setCurrentProfile(profile);
+            }
+            else
+                debug("profiles",
+                        "Not setting profile to the current profile " + profile.getName());
         }
+
     }
 }
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/templates/TemplateDetailsAdapter.java b/app/src/main/java/net/ktnx/mobileledger/ui/templates/TemplateDetailsAdapter.java
new file mode 100644 (file)
index 0000000..ccf1b81
--- /dev/null
@@ -0,0 +1,940 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui.templates;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.content.res.Resources;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.recyclerview.widget.AsyncListDiffer;
+import androidx.recyclerview.widget.DiffUtil;
+import androidx.recyclerview.widget.ItemTouchHelper;
+import androidx.recyclerview.widget.RecyclerView;
+
+import net.ktnx.mobileledger.BuildConfig;
+import net.ktnx.mobileledger.R;
+import net.ktnx.mobileledger.databinding.TemplateDetailsAccountBinding;
+import net.ktnx.mobileledger.databinding.TemplateDetailsHeaderBinding;
+import net.ktnx.mobileledger.db.AccountAutocompleteAdapter;
+import net.ktnx.mobileledger.db.DB;
+import net.ktnx.mobileledger.model.Data;
+import net.ktnx.mobileledger.model.TemplateDetailsItem;
+import net.ktnx.mobileledger.ui.CurrencySelectorFragment;
+import net.ktnx.mobileledger.ui.HelpDialog;
+import net.ktnx.mobileledger.ui.QR;
+import net.ktnx.mobileledger.ui.TemplateDetailSourceSelectorFragment;
+import net.ktnx.mobileledger.utils.Logger;
+import net.ktnx.mobileledger.utils.Misc;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.text.ParseException;
+import java.util.List;
+import java.util.Locale;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+class TemplateDetailsAdapter extends RecyclerView.Adapter<TemplateDetailsAdapter.ViewHolder> {
+    private static final String D_TEMPLATE_UI = "template-ui";
+    private final AsyncListDiffer<TemplateDetailsItem> differ;
+    private final TemplateDetailsViewModel mModel;
+    private final ItemTouchHelper itemTouchHelper;
+    public TemplateDetailsAdapter(TemplateDetailsViewModel model) {
+        super();
+        mModel = model;
+        setHasStableIds(true);
+        differ = new AsyncListDiffer<>(this, new DiffUtil.ItemCallback<TemplateDetailsItem>() {
+            @Override
+            public boolean areItemsTheSame(@NonNull TemplateDetailsItem oldItem,
+                                           @NonNull TemplateDetailsItem newItem) {
+                if (oldItem.getType() != newItem.getType())
+                    return false;
+                if (oldItem.getType()
+                           .equals(TemplateDetailsItem.Type.HEADER))
+                    return true;    // only one header item, ever
+                // the rest is comparing two account row items
+                return oldItem.asAccountRowItem()
+                              .getId() == newItem.asAccountRowItem()
+                                                 .getId();
+            }
+            @Override
+            public boolean areContentsTheSame(@NonNull TemplateDetailsItem oldItem,
+                                              @NonNull TemplateDetailsItem newItem) {
+                if (oldItem.getType()
+                           .equals(TemplateDetailsItem.Type.HEADER))
+                {
+                    TemplateDetailsItem.Header oldHeader = oldItem.asHeaderItem();
+                    TemplateDetailsItem.Header newHeader = newItem.asHeaderItem();
+
+                    return oldHeader.equalContents(newHeader);
+                }
+                else {
+                    TemplateDetailsItem.AccountRow oldAcc = oldItem.asAccountRowItem();
+                    TemplateDetailsItem.AccountRow newAcc = newItem.asAccountRowItem();
+
+                    return oldAcc.equalContents(newAcc);
+                }
+            }
+        });
+        itemTouchHelper = new ItemTouchHelper(new ItemTouchHelper.Callback() {
+            @Override
+            public float getMoveThreshold(@NonNull RecyclerView.ViewHolder viewHolder) {
+                return 0.5f;
+            }
+            @Override
+            public boolean isLongPressDragEnabled() {
+                return false;
+            }
+            @Override
+            public RecyclerView.ViewHolder chooseDropTarget(
+                    @NonNull RecyclerView.ViewHolder selected,
+                    @NonNull List<RecyclerView.ViewHolder> dropTargets, int curX, int curY) {
+                RecyclerView.ViewHolder best = null;
+                int bestDistance = 0;
+                for (RecyclerView.ViewHolder v : dropTargets) {
+                    if (v == selected)
+                        continue;
+
+                    final int viewTop = v.itemView.getTop();
+                    int distance = Math.abs(viewTop - curY);
+                    if (best == null) {
+                        best = v;
+                        bestDistance = distance;
+                    }
+                    else {
+                        if (distance < bestDistance) {
+                            bestDistance = distance;
+                            best = v;
+                        }
+                    }
+                }
+
+                Logger.debug("dnd", "Best target is " + best);
+                return best;
+            }
+            @Override
+            public boolean canDropOver(@NonNull RecyclerView recyclerView,
+                                       @NonNull RecyclerView.ViewHolder current,
+                                       @NonNull RecyclerView.ViewHolder target) {
+                final int adapterPosition = target.getBindingAdapterPosition();
+
+                // first item is immovable
+                if (adapterPosition == 0)
+                    return false;
+
+                return super.canDropOver(recyclerView, current, target);
+            }
+            @Override
+            public int getMovementFlags(@NonNull RecyclerView recyclerView,
+                                        @NonNull RecyclerView.ViewHolder viewHolder) {
+                int flags = 0;
+                // the top item (transaction params) is always there
+                final int adapterPosition = viewHolder.getBindingAdapterPosition();
+                if (adapterPosition > 0)
+                    flags |= makeFlag(ItemTouchHelper.ACTION_STATE_DRAG,
+                            ItemTouchHelper.UP | ItemTouchHelper.DOWN) |
+                             makeFlag(ItemTouchHelper.ACTION_STATE_SWIPE,
+                                     ItemTouchHelper.START | ItemTouchHelper.END);
+
+                return flags;
+            }
+            @Override
+            public boolean onMove(@NonNull RecyclerView recyclerView,
+                                  @NonNull RecyclerView.ViewHolder viewHolder,
+                                  @NonNull RecyclerView.ViewHolder target) {
+
+                final int fromPosition = viewHolder.getBindingAdapterPosition();
+                final int toPosition = target.getBindingAdapterPosition();
+                if (fromPosition == toPosition) {
+                    Logger.debug("drag", String.format(Locale.US,
+                            "Ignoring request to move an account from position %d to %d",
+                            fromPosition, toPosition));
+                    return false;
+                }
+
+                Logger.debug("drag",
+                        String.format(Locale.US, "Moving account from %d to %d", fromPosition,
+                                toPosition));
+                mModel.moveItem(fromPosition, toPosition);
+
+                return true;
+            }
+            @Override
+            public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) {
+                int pos = viewHolder.getBindingAdapterPosition();
+                mModel.removeItem(pos);
+            }
+        });
+    }
+    @Override
+    public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
+        super.onAttachedToRecyclerView(recyclerView);
+
+        itemTouchHelper.attachToRecyclerView(recyclerView);
+    }
+    @Override
+    public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) {
+        super.onDetachedFromRecyclerView(recyclerView);
+
+        itemTouchHelper.attachToRecyclerView(null);
+    }
+    @Override
+    public long getItemId(int position) {
+        // header item is always first and IDs id may duplicate some of the account IDs
+        if (position == 0)
+            return 0;
+        TemplateDetailsItem.AccountRow accRow = differ.getCurrentList()
+                                                      .get(position)
+                                                      .asAccountRowItem();
+        return accRow.getId();
+    }
+    @Override
+    public int getItemViewType(int position) {
+
+        return differ.getCurrentList()
+                     .get(position)
+                     .getType()
+                     .toInt();
+    }
+    @NonNull
+    @Override
+    public TemplateDetailsAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent,
+                                                                int viewType) {
+        final LayoutInflater inflater = LayoutInflater.from(parent.getContext());
+        switch (viewType) {
+            case TemplateDetailsItem.TYPE.header:
+                return new Header(TemplateDetailsHeaderBinding.inflate(inflater, parent, false));
+            case TemplateDetailsItem.TYPE.accountItem:
+                return new AccountRow(
+                        TemplateDetailsAccountBinding.inflate(inflater, parent, false));
+            default:
+                throw new IllegalStateException("Unsupported view type " + viewType);
+        }
+    }
+    @Override
+    public void onBindViewHolder(@NonNull TemplateDetailsAdapter.ViewHolder holder, int position) {
+        TemplateDetailsItem item = differ.getCurrentList()
+                                         .get(position);
+        holder.bind(item);
+    }
+    @Override
+    public int getItemCount() {
+        return differ.getCurrentList()
+                     .size();
+    }
+    public void setItems(List<TemplateDetailsItem> items) {
+        if (BuildConfig.DEBUG) {
+            Logger.debug("tmpl", "Got new list");
+            for (int i = 1; i < items.size(); i++) {
+                final TemplateDetailsItem item = items.get(i);
+                Logger.debug("tmpl",
+                        String.format(Locale.US, "  %d: id %d, pos %d", i, item.getId(),
+                                item.getPosition()));
+            }
+        }
+        differ.submitList(items);
+    }
+    public String getMatchGroupText(int groupNumber) {
+        TemplateDetailsItem.Header header = getHeader();
+        Pattern p = header.getCompiledPattern();
+        if (p == null)
+            return null;
+
+        final String testText = Misc.nullIsEmpty(header.getTestText());
+        Matcher m = p.matcher(testText);
+        if (m.matches() && m.groupCount() >= groupNumber)
+            return m.group(groupNumber);
+        else
+            return null;
+    }
+    protected TemplateDetailsItem.Header getHeader() {
+        return differ.getCurrentList()
+                     .get(0)
+                     .asHeaderItem();
+    }
+
+    private enum HeaderDetail {DESCRIPTION, COMMENT, DATE_YEAR, DATE_MONTH, DATE_DAY}
+
+    private enum AccDetail {ACCOUNT, COMMENT, AMOUNT, CURRENCY}
+
+    public abstract static class ViewHolder extends RecyclerView.ViewHolder {
+        ViewHolder(@NonNull View itemView) {
+            super(itemView);
+        }
+        abstract void bind(TemplateDetailsItem item);
+    }
+
+    private abstract static class BaseItem extends ViewHolder {
+        boolean updatePropagationDisabled = false;
+        BaseItem(@NonNull View itemView) {
+            super(itemView);
+        }
+        void disableUpdatePropagation() {
+            updatePropagationDisabled = true;
+        }
+        void enableUpdatePropagation() {
+            updatePropagationDisabled = false;
+        }
+    }
+
+    public class Header extends BaseItem {
+        private final TemplateDetailsHeaderBinding b;
+        boolean updatePropagationDisabled = false;
+        public Header(@NonNull TemplateDetailsHeaderBinding binding) {
+            super(binding.getRoot());
+            b = binding;
+
+            TextWatcher templateNameWatcher = new TextWatcher() {
+                @Override
+                public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
+                @Override
+                public void onTextChanged(CharSequence s, int start, int before, int count) {}
+                @Override
+                public void afterTextChanged(Editable s) {
+                    if (updatePropagationDisabled)
+                        return;
+
+                    final TemplateDetailsItem.Header header = getItem();
+                    Logger.debug(D_TEMPLATE_UI,
+                            "Storing changed template name " + s + "; header=" + header);
+                    header.setName(String.valueOf(s));
+                }
+            };
+            b.templateName.addTextChangedListener(templateNameWatcher);
+
+            TextWatcher patternWatcher = new TextWatcher() {
+                @Override
+                public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
+                @Override
+                public void onTextChanged(CharSequence s, int start, int before, int count) {}
+                @Override
+                public void afterTextChanged(Editable s) {
+                    if (updatePropagationDisabled)
+                        return;
+
+                    final TemplateDetailsItem.Header header = getItem();
+                    Logger.debug(D_TEMPLATE_UI,
+                            "Storing changed pattern " + s + "; header=" + header);
+                    header.setPattern(String.valueOf(s));
+
+                    checkPatternError(header);
+                }
+            };
+            b.pattern.addTextChangedListener(patternWatcher);
+
+            TextWatcher testTextWatcher = new TextWatcher() {
+                @Override
+                public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
+                @Override
+                public void onTextChanged(CharSequence s, int start, int before, int count) {}
+                @Override
+                public void afterTextChanged(Editable s) {
+                    if (updatePropagationDisabled)
+                        return;
+
+                    final TemplateDetailsItem.Header header = getItem();
+                    Logger.debug(D_TEMPLATE_UI,
+                            "Storing changed test text " + s + "; header=" + header);
+                    header.setTestText(String.valueOf(s));
+
+                    checkPatternError(header);
+                }
+            };
+            b.testText.addTextChangedListener(testTextWatcher);
+
+            TextWatcher transactionDescriptionWatcher = new TextWatcher() {
+                @Override
+                public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+                }
+                @Override
+                public void onTextChanged(CharSequence s, int start, int before, int count) {
+                }
+                @Override
+                public void afterTextChanged(Editable s) {
+                    if (updatePropagationDisabled)
+                        return;
+
+                    final TemplateDetailsItem.Header header = getItem();
+                    Logger.debug(D_TEMPLATE_UI,
+                            "Storing changed transaction description " + s + "; header=" + header);
+                    header.setTransactionDescription(String.valueOf(s));
+                }
+            };
+            b.transactionDescription.addTextChangedListener(transactionDescriptionWatcher);
+            TextWatcher transactionCommentWatcher = new TextWatcher() {
+                @Override
+                public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+                }
+                @Override
+                public void onTextChanged(CharSequence s, int start, int before, int count) {
+                }
+                @Override
+                public void afterTextChanged(Editable s) {
+                    if (updatePropagationDisabled)
+                        return;
+
+                    final TemplateDetailsItem.Header header = getItem();
+                    Logger.debug(D_TEMPLATE_UI,
+                            "Storing changed transaction description " + s + "; header=" + header);
+                    header.setTransactionComment(String.valueOf(s));
+                }
+            };
+            b.transactionComment.addTextChangedListener(transactionCommentWatcher);
+
+            b.templateIsFallbackSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
+                if (updatePropagationDisabled)
+                    return;
+
+                getItem().setFallback(isChecked);
+                b.templateIsFallbackText.setText(isChecked ? R.string.template_is_fallback_yes
+                                                           : R.string.template_is_fallback_no);
+            });
+            final View.OnClickListener fallbackLabelClickListener =
+                    (view) -> b.templateIsFallbackSwitch.toggle();
+            b.templateIsFallbackLabel.setOnClickListener(fallbackLabelClickListener);
+            b.templateIsFallbackText.setOnClickListener(fallbackLabelClickListener);
+            b.templateParamsHelpButton.setOnClickListener(v -> HelpDialog.show(b.getRoot()
+                                                                                .getContext(),
+                    R.string.template_details_template_params_label, R.array.template_params_help));
+        }
+        @NotNull
+        private TemplateDetailsItem.Header getItem() {
+            int pos = getBindingAdapterPosition();
+            return differ.getCurrentList()
+                         .get(pos)
+                         .asHeaderItem();
+        }
+        private void selectHeaderDetailSource(View v, HeaderDetail detail) {
+            TemplateDetailsItem.Header header = getItem();
+            Logger.debug(D_TEMPLATE_UI, "header is " + header);
+            TemplateDetailSourceSelectorFragment sel =
+                    TemplateDetailSourceSelectorFragment.newInstance(1, header.getPattern(),
+                            header.getTestText());
+            sel.setOnSourceSelectedListener((literal, group) -> {
+                if (literal) {
+                    switch (detail) {
+                        case DESCRIPTION:
+                            header.switchToLiteralTransactionDescription();
+                            break;
+                        case COMMENT:
+                            header.switchToLiteralTransactionComment();
+                            break;
+                        case DATE_YEAR:
+                            header.switchToLiteralDateYear();
+                            break;
+                        case DATE_MONTH:
+                            header.switchToLiteralDateMonth();
+                            break;
+                        case DATE_DAY:
+                            header.switchToLiteralDateDay();
+                            break;
+                        default:
+                            throw new IllegalStateException("Unexpected detail " + detail);
+                    }
+                }
+                else {
+                    switch (detail) {
+                        case DESCRIPTION:
+                            header.setTransactionDescriptionMatchGroup(group);
+                            break;
+                        case COMMENT:
+                            header.setTransactionCommentMatchGroup(group);
+                            break;
+                        case DATE_YEAR:
+                            header.setDateYearMatchGroup(group);
+                            break;
+                        case DATE_MONTH:
+                            header.setDateMonthMatchGroup(group);
+                            break;
+                        case DATE_DAY:
+                            header.setDateDayMatchGroup(group);
+                            break;
+                        default:
+                            throw new IllegalStateException("Unexpected detail " + detail);
+                    }
+                }
+
+                notifyItemChanged(getBindingAdapterPosition());
+            });
+            final AppCompatActivity activity = (AppCompatActivity) v.getContext();
+            sel.show(activity.getSupportFragmentManager(), "template-details-source-selector");
+        }
+        @Override
+        void bind(TemplateDetailsItem item) {
+            TemplateDetailsItem.Header header = item.asHeaderItem();
+            Logger.debug(D_TEMPLATE_UI, "Binding to header " + header);
+
+            disableUpdatePropagation();
+            try {
+                String groupNoText = b.getRoot()
+                                      .getResources()
+                                      .getString(R.string.template_item_match_group_source);
+
+                b.templateName.setText(header.getName());
+                b.pattern.setText(header.getPattern());
+                b.testText.setText(header.getTestText());
+
+                if (header.hasLiteralDateYear()) {
+                    b.yearSource.setText(R.string.template_details_source_literal);
+                    final Integer dateYear = header.getDateYear();
+                    b.templateDetailsDateYear.setText(
+                            (dateYear == null) ? null : String.valueOf(dateYear));
+                    b.yearLayout.setVisibility(View.VISIBLE);
+                }
+                else {
+                    b.yearLayout.setVisibility(View.GONE);
+                    b.yearSource.setText(
+                            String.format(Locale.US, groupNoText, header.getDateYearMatchGroup(),
+                                    getMatchGroupText(header.getDateYearMatchGroup())));
+                }
+                b.yearSourceLabel.setOnClickListener(
+                        v -> selectHeaderDetailSource(v, HeaderDetail.DATE_YEAR));
+                b.yearSource.setOnClickListener(
+                        v -> selectHeaderDetailSource(v, HeaderDetail.DATE_YEAR));
+
+                if (header.hasLiteralDateMonth()) {
+                    b.monthSource.setText(R.string.template_details_source_literal);
+                    final Integer dateMonth = header.getDateMonth();
+                    b.templateDetailsDateMonth.setText(
+                            (dateMonth == null) ? null : String.valueOf(dateMonth));
+                    b.monthLayout.setVisibility(View.VISIBLE);
+                }
+                else {
+                    b.monthLayout.setVisibility(View.GONE);
+                    b.monthSource.setText(
+                            String.format(Locale.US, groupNoText, header.getDateMonthMatchGroup(),
+                                    getMatchGroupText(header.getDateMonthMatchGroup())));
+                }
+                b.monthSourceLabel.setOnClickListener(
+                        v -> selectHeaderDetailSource(v, HeaderDetail.DATE_MONTH));
+                b.monthSource.setOnClickListener(
+                        v -> selectHeaderDetailSource(v, HeaderDetail.DATE_MONTH));
+
+                if (header.hasLiteralDateDay()) {
+                    b.daySource.setText(R.string.template_details_source_literal);
+                    final Integer dateDay = header.getDateDay();
+                    b.templateDetailsDateDay.setText(
+                            (dateDay == null) ? null : String.valueOf(dateDay));
+                    b.dayLayout.setVisibility(View.VISIBLE);
+                }
+                else {
+                    b.dayLayout.setVisibility(View.GONE);
+                    b.daySource.setText(
+                            String.format(Locale.US, groupNoText, header.getDateDayMatchGroup(),
+                                    getMatchGroupText(header.getDateDayMatchGroup())));
+                }
+                b.daySourceLabel.setOnClickListener(
+                        v -> selectHeaderDetailSource(v, HeaderDetail.DATE_DAY));
+                b.daySource.setOnClickListener(
+                        v -> selectHeaderDetailSource(v, HeaderDetail.DATE_DAY));
+
+                if (header.hasLiteralTransactionDescription()) {
+                    b.templateTransactionDescriptionSource.setText(
+                            R.string.template_details_source_literal);
+                    b.transactionDescription.setText(header.getTransactionDescription());
+                    b.transactionDescriptionLayout.setVisibility(View.VISIBLE);
+                }
+                else {
+                    b.transactionDescriptionLayout.setVisibility(View.GONE);
+                    b.templateTransactionDescriptionSource.setText(
+                            String.format(Locale.US, groupNoText,
+                                    header.getTransactionDescriptionMatchGroup(), getMatchGroupText(
+                                            header.getTransactionDescriptionMatchGroup())));
+
+                }
+                b.templateTransactionDescriptionSourceLabel.setOnClickListener(
+                        v -> selectHeaderDetailSource(v, HeaderDetail.DESCRIPTION));
+                b.templateTransactionDescriptionSource.setOnClickListener(
+                        v -> selectHeaderDetailSource(v, HeaderDetail.DESCRIPTION));
+
+                if (header.hasLiteralTransactionComment()) {
+                    b.templateTransactionCommentSource.setText(
+                            R.string.template_details_source_literal);
+                    b.transactionComment.setText(header.getTransactionComment());
+                    b.transactionCommentLayout.setVisibility(View.VISIBLE);
+                }
+                else {
+                    b.transactionCommentLayout.setVisibility(View.GONE);
+                    b.templateTransactionCommentSource.setText(String.format(Locale.US, groupNoText,
+                            header.getTransactionCommentMatchGroup(),
+                            getMatchGroupText(header.getTransactionCommentMatchGroup())));
+
+                }
+                b.templateTransactionCommentSourceLabel.setOnClickListener(
+                        v -> selectHeaderDetailSource(v, HeaderDetail.COMMENT));
+                b.templateTransactionCommentSource.setOnClickListener(
+                        v -> selectHeaderDetailSource(v, HeaderDetail.COMMENT));
+
+                b.templateDetailsHeadScanQrButton.setOnClickListener(this::scanTestQR);
+
+                b.templateIsFallbackSwitch.setChecked(header.isFallback());
+                b.templateIsFallbackText.setText(
+                        header.isFallback() ? R.string.template_is_fallback_yes
+                                            : R.string.template_is_fallback_no);
+
+                checkPatternError(header);
+            }
+            finally {
+                enableUpdatePropagation();
+            }
+        }
+        private void checkPatternError(TemplateDetailsItem.Header item) {
+            if (item.getPatternError() != null) {
+                b.patternLayout.setError(item.getPatternError());
+                b.patternHintTitle.setVisibility(View.GONE);
+                b.patternHintText.setVisibility(View.GONE);
+            }
+            else {
+                b.patternLayout.setError(null);
+                if (item.testMatch() != null) {
+                    b.patternHintText.setText(item.testMatch());
+                    b.patternHintTitle.setVisibility(View.VISIBLE);
+                    b.patternHintText.setVisibility(View.VISIBLE);
+                }
+                else {
+                    b.patternLayout.setError(null);
+                    b.patternHintTitle.setVisibility(View.GONE);
+                    b.patternHintText.setVisibility(View.GONE);
+                }
+            }
+
+        }
+        private void scanTestQR(View view) {
+            Context ctx = view.getContext();
+            if (ctx instanceof QR.QRScanTrigger)
+                ((QR.QRScanTrigger) ctx).triggerQRScan();
+        }
+    }
+
+    public class AccountRow extends BaseItem {
+        private final TemplateDetailsAccountBinding b;
+        public AccountRow(@NonNull TemplateDetailsAccountBinding binding) {
+            super(binding.getRoot());
+            b = binding;
+
+            TextWatcher accountNameWatcher = new TextWatcher() {
+                @Override
+                public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
+                @Override
+                public void onTextChanged(CharSequence s, int start, int before, int count) {}
+                @Override
+                public void afterTextChanged(Editable s) {
+                    if (updatePropagationDisabled)
+                        return;
+
+                    TemplateDetailsItem.AccountRow accRow = getItem();
+                    Logger.debug(D_TEMPLATE_UI,
+                            "Storing changed account name " + s + "; accRow=" + accRow);
+                    accRow.setAccountName(String.valueOf(s));
+
+                    mModel.applyList(null);
+                }
+            };
+            b.templateDetailsAccountName.addTextChangedListener(accountNameWatcher);
+            b.templateDetailsAccountName.setAdapter(new AccountAutocompleteAdapter(b.getRoot()
+                                                                                    .getContext()));
+            b.templateDetailsAccountName.setOnItemClickListener(
+                    (parent, view, position, id) -> b.templateDetailsAccountName.setText(
+                            ((TextView) view).getText()));
+            TextWatcher accountCommentWatcher = new TextWatcher() {
+                @Override
+                public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
+                @Override
+                public void onTextChanged(CharSequence s, int start, int before, int count) {}
+                @Override
+                public void afterTextChanged(Editable s) {
+                    if (updatePropagationDisabled)
+                        return;
+
+                    TemplateDetailsItem.AccountRow accRow = getItem();
+                    Logger.debug(D_TEMPLATE_UI,
+                            "Storing changed account comment " + s + "; accRow=" + accRow);
+                    accRow.setAccountComment(String.valueOf(s));
+
+                    mModel.applyList(null);
+                }
+            };
+            b.templateDetailsAccountComment.addTextChangedListener(accountCommentWatcher);
+
+            b.templateDetailsAccountAmount.addTextChangedListener(new TextWatcher() {
+                @Override
+                public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+                }
+                @Override
+                public void onTextChanged(CharSequence s, int start, int before, int count) {
+                }
+                @Override
+                public void afterTextChanged(Editable s) {
+                    if (updatePropagationDisabled)
+                        return;
+
+                    TemplateDetailsItem.AccountRow accRow = getItem();
+
+                    String str = String.valueOf(s);
+                    if (Misc.emptyIsNull(str) == null) {
+                        accRow.setAmount(null);
+                    }
+                    else {
+                        try {
+                            final float amount = Data.parseNumber(str);
+                            accRow.setAmount(amount);
+                            b.templateDetailsAccountAmountLayout.setError(null);
+
+                            Logger.debug(D_TEMPLATE_UI, String.format(Locale.US,
+                                    "Storing changed account amount %s [%4.2f]; accRow=%s", s,
+                                    amount, accRow));
+                        }
+                        catch (NumberFormatException | ParseException e) {
+                            b.templateDetailsAccountAmountLayout.setError("!");
+                        }
+                    }
+
+                    mModel.applyList(null);
+                }
+            });
+            b.templateDetailsAccountAmount.setOnFocusChangeListener((v, hasFocus) -> {
+                if (hasFocus)
+                    return;
+
+                TemplateDetailsItem.AccountRow accRow = getItem();
+                if (!accRow.hasLiteralAmount())
+                    return;
+                Float amt = accRow.getAmount();
+                if (amt == null)
+                    return;
+
+                b.templateDetailsAccountAmount.setText(Data.formatNumber(amt));
+            });
+
+            b.negateAmountSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
+                if (updatePropagationDisabled)
+                    return;
+
+                getItem().setNegateAmount(isChecked);
+                b.templateDetailsNegateAmountText.setText(
+                        isChecked ? R.string.template_account_change_amount_sign
+                                  : R.string.template_account_keep_amount_sign);
+            });
+            final View.OnClickListener negLabelClickListener =
+                    (view) -> b.negateAmountSwitch.toggle();
+            b.templateDetailsNegateAmountLabel.setOnClickListener(negLabelClickListener);
+            b.templateDetailsNegateAmountText.setOnClickListener(negLabelClickListener);
+            manageAccountLabelDrag();
+        }
+        @SuppressLint("ClickableViewAccessibility")
+        public void manageAccountLabelDrag() {
+            b.patternAccountLabel.setOnTouchListener((v, event) -> {
+                if (event.getAction() == MotionEvent.ACTION_DOWN) {
+                    itemTouchHelper.startDrag(this);
+                }
+                return false;
+            });
+        }
+        @Override
+        void bind(TemplateDetailsItem item) {
+            disableUpdatePropagation();
+            try {
+                final Resources resources = b.getRoot()
+                                             .getResources();
+                String groupNoText = resources.getString(R.string.template_item_match_group_source);
+
+                Logger.debug("drag", String.format(Locale.US, "Binding account id %d, pos %d at %d",
+                        item.getId(), item.getPosition(), getBindingAdapterPosition()));
+                TemplateDetailsItem.AccountRow accRow = item.asAccountRowItem();
+                b.patternAccountLabel.setText(String.format(Locale.US,
+                        resources.getString(R.string.template_details_account_row_label),
+                        accRow.getPosition()));
+                if (accRow.hasLiteralAccountName()) {
+                    b.templateDetailsAccountNameLayout.setVisibility(View.VISIBLE);
+                    b.templateDetailsAccountName.setText(accRow.getAccountName());
+                    b.templateDetailsAccountNameSource.setText(
+                            R.string.template_details_source_literal);
+                }
+                else {
+                    b.templateDetailsAccountNameLayout.setVisibility(View.GONE);
+                    b.templateDetailsAccountNameSource.setText(
+                            String.format(Locale.US, groupNoText, accRow.getAccountNameMatchGroup(),
+                                    getMatchGroupText(accRow.getAccountNameMatchGroup())));
+                }
+
+                if (accRow.hasLiteralAccountComment()) {
+                    b.templateDetailsAccountCommentLayout.setVisibility(View.VISIBLE);
+                    b.templateDetailsAccountComment.setText(accRow.getAccountComment());
+                    b.templateDetailsAccountCommentSource.setText(
+                            R.string.template_details_source_literal);
+                }
+                else {
+                    b.templateDetailsAccountCommentLayout.setVisibility(View.GONE);
+                    b.templateDetailsAccountCommentSource.setText(
+                            String.format(Locale.US, groupNoText,
+                                    accRow.getAccountCommentMatchGroup(),
+                                    getMatchGroupText(accRow.getAccountCommentMatchGroup())));
+                }
+
+                if (accRow.hasLiteralAmount()) {
+                    b.templateDetailsAccountAmountSource.setText(
+                            R.string.template_details_source_literal);
+                    b.templateDetailsAccountAmount.setVisibility(View.VISIBLE);
+                    Float amt = accRow.getAmount();
+                    b.templateDetailsAccountAmount.setText((amt == null) ? null : String.format(
+                            Data.locale.getValue(), "%,4.2f", (accRow.getAmount())));
+                    b.negateAmountSwitch.setVisibility(View.GONE);
+                    b.templateDetailsNegateAmountLabel.setVisibility(View.GONE);
+                    b.templateDetailsNegateAmountText.setVisibility(View.GONE);
+                }
+                else {
+                    b.templateDetailsAccountAmountSource.setText(
+                            String.format(Locale.US, groupNoText, accRow.getAmountMatchGroup(),
+                                    getMatchGroupText(accRow.getAmountMatchGroup())));
+                    b.templateDetailsAccountAmountLayout.setVisibility(View.GONE);
+                    b.negateAmountSwitch.setVisibility(View.VISIBLE);
+                    b.negateAmountSwitch.setChecked(accRow.isNegateAmount());
+                    b.templateDetailsNegateAmountText.setText(
+                            accRow.isNegateAmount() ? R.string.template_account_change_amount_sign
+                                                    : R.string.template_account_keep_amount_sign);
+                    b.templateDetailsNegateAmountLabel.setVisibility(View.VISIBLE);
+                    b.templateDetailsNegateAmountText.setVisibility(View.VISIBLE);
+                }
+
+                if (accRow.hasLiteralCurrency()) {
+                    b.templateDetailsAccountCurrencySource.setText(
+                            R.string.template_details_source_literal);
+                    net.ktnx.mobileledger.db.Currency c = accRow.getCurrency();
+                    if (c == null)
+                        b.templateDetailsAccountCurrency.setText(R.string.btn_no_currency);
+                    else
+                        b.templateDetailsAccountCurrency.setText(c.getName());
+                    b.templateDetailsAccountCurrency.setVisibility(View.VISIBLE);
+                }
+                else {
+                    b.templateDetailsAccountCurrencySource.setText(
+                            String.format(Locale.US, groupNoText, accRow.getCurrencyMatchGroup(),
+                                    getMatchGroupText(accRow.getCurrencyMatchGroup())));
+                    b.templateDetailsAccountCurrency.setVisibility(View.GONE);
+                }
+
+                b.templateAccountNameSourceLabel.setOnClickListener(
+                        v -> selectAccountRowDetailSource(v, AccDetail.ACCOUNT));
+                b.templateDetailsAccountNameSource.setOnClickListener(
+                        v -> selectAccountRowDetailSource(v, AccDetail.ACCOUNT));
+                b.templateAccountCommentSourceLabel.setOnClickListener(
+                        v -> selectAccountRowDetailSource(v, AccDetail.COMMENT));
+                b.templateDetailsAccountCommentSource.setOnClickListener(
+                        v -> selectAccountRowDetailSource(v, AccDetail.COMMENT));
+                b.templateAccountAmountSourceLabel.setOnClickListener(
+                        v -> selectAccountRowDetailSource(v, AccDetail.AMOUNT));
+                b.templateDetailsAccountAmountSource.setOnClickListener(
+                        v -> selectAccountRowDetailSource(v, AccDetail.AMOUNT));
+                b.templateDetailsAccountCurrencySource.setOnClickListener(
+                        v -> selectAccountRowDetailSource(v, AccDetail.CURRENCY));
+                b.templateAccountCurrencySourceLabel.setOnClickListener(
+                        v -> selectAccountRowDetailSource(v, AccDetail.CURRENCY));
+                if (accRow.hasLiteralCurrency())
+                    b.templateDetailsAccountCurrency.setOnClickListener(v -> {
+                        CurrencySelectorFragment cpf = CurrencySelectorFragment.newInstance(
+                                CurrencySelectorFragment.DEFAULT_COLUMN_COUNT, false);
+                        cpf.setOnCurrencySelectedListener(text -> {
+                            if (text == null) {
+                                b.templateDetailsAccountCurrency.setText(R.string.btn_no_currency);
+                                accRow.setCurrency(null);
+                            }
+                            else {
+                                b.templateDetailsAccountCurrency.setText(text);
+                                DB.get()
+                                  .getCurrencyDAO()
+                                  .getByName(text)
+                                  .observe((LifecycleOwner) b.getRoot()
+                                                             .getContext(), accRow::setCurrency);
+                            }
+                        });
+                        cpf.show(
+                                ((TemplatesActivity) b.templateDetailsAccountCurrency.getContext()).getSupportFragmentManager(),
+                                "currency-selector");
+                    });
+            }
+            finally {
+                enableUpdatePropagation();
+            }
+        }
+        private @NotNull TemplateDetailsItem.AccountRow getItem() {
+            return differ.getCurrentList()
+                         .get(getBindingAdapterPosition())
+                         .asAccountRowItem();
+        }
+        private void selectAccountRowDetailSource(View v, AccDetail detail) {
+            TemplateDetailsItem.AccountRow accRow = getItem();
+            final TemplateDetailsItem.Header header = getHeader();
+            Logger.debug(D_TEMPLATE_UI, "header is " + header);
+            TemplateDetailSourceSelectorFragment sel =
+                    TemplateDetailSourceSelectorFragment.newInstance(1, header.getPattern(),
+                            header.getTestText());
+            sel.setOnSourceSelectedListener((literal, group) -> {
+                if (literal) {
+                    switch (detail) {
+                        case ACCOUNT:
+                            accRow.switchToLiteralAccountName();
+                            break;
+                        case COMMENT:
+                            accRow.switchToLiteralAccountComment();
+                            break;
+                        case AMOUNT:
+                            accRow.switchToLiteralAmount();
+                            break;
+                        case CURRENCY:
+                            accRow.switchToLiteralCurrency();
+                            break;
+                        default:
+                            throw new IllegalStateException("Unexpected detail " + detail);
+                    }
+                }
+                else {
+                    switch (detail) {
+                        case ACCOUNT:
+                            accRow.setAccountNameMatchGroup(group);
+                            break;
+                        case COMMENT:
+                            accRow.setAccountCommentMatchGroup(group);
+                            break;
+                        case AMOUNT:
+                            accRow.setAmountMatchGroup(group);
+                            break;
+                        case CURRENCY:
+                            accRow.setCurrencyMatchGroup(group);
+                            break;
+                        default:
+                            throw new IllegalStateException("Unexpected detail " + detail);
+                    }
+                }
+
+                notifyItemChanged(getBindingAdapterPosition());
+            });
+            final AppCompatActivity activity = (AppCompatActivity) v.getContext();
+            sel.show(activity.getSupportFragmentManager(), "template-details-source-selector");
+        }
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/templates/TemplateDetailsFragment.java b/app/src/main/java/net/ktnx/mobileledger/ui/templates/TemplateDetailsFragment.java
new file mode 100644 (file)
index 0000000..03dd0dd
--- /dev/null
@@ -0,0 +1,136 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui.templates;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentActivity;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.lifecycle.ViewModelStoreOwner;
+import androidx.navigation.NavController;
+import androidx.recyclerview.widget.GridLayoutManager;
+import androidx.recyclerview.widget.LinearLayoutManager;
+
+import net.ktnx.mobileledger.R;
+import net.ktnx.mobileledger.databinding.TemplateDetailsFragmentBinding;
+import net.ktnx.mobileledger.ui.FabManager;
+import net.ktnx.mobileledger.utils.Logger;
+
+public class TemplateDetailsFragment extends Fragment {
+    static final String ARG_TEMPLATE_ID = "pattern-id";
+    private static final String ARG_COLUMN_COUNT = "column-count";
+    private TemplateDetailsFragmentBinding b;
+    private TemplateDetailsViewModel mViewModel;
+    private int mColumnCount = 1;
+    private Long mPatternId;
+    private InteractionListener interactionListener;
+    public TemplateDetailsFragment() {
+    }
+    public static TemplateDetailsFragment newInstance(int columnCount, int patternId) {
+        final TemplateDetailsFragment fragment = new TemplateDetailsFragment();
+        Bundle args = new Bundle();
+        args.putInt(ARG_COLUMN_COUNT, columnCount);
+        if (patternId > 0)
+            args.putInt(ARG_TEMPLATE_ID, patternId);
+        fragment.setArguments(args);
+        return fragment;
+    }
+    @Override
+    public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
+        super.onCreateOptionsMenu(menu, inflater);
+        inflater.inflate(R.menu.template_details_menu, menu);
+    }
+    @Override
+    public boolean onOptionsItemSelected(@NonNull MenuItem item) {
+        if (item.getItemId() == R.id.delete_template) {
+            signalDeleteTemplateInteraction();
+            return true;
+        }
+
+        return super.onOptionsItemSelected(item);
+    }
+    private void signalDeleteTemplateInteraction() {
+        if (interactionListener != null)
+            interactionListener.onDeleteTemplate(mPatternId);
+    }
+    @Override
+    public void onCreate(@Nullable Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+
+        final Bundle args = getArguments();
+        if (args != null) {
+            mColumnCount = args.getInt(ARG_COLUMN_COUNT, 1);
+            mPatternId = args.getLong(ARG_TEMPLATE_ID, -1);
+            if (mPatternId == -1)
+                mPatternId = null;
+        }
+
+        setHasOptionsMenu(mPatternId != null);
+    }
+    @Override
+    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
+                             @Nullable Bundle savedInstanceState) {
+        if (!(getActivity() instanceof InteractionListener))
+            throw new IllegalStateException(
+                    "Containing activity must implement TemplateDetailsFragment" +
+                    ".InteractionListener");
+        interactionListener = (InteractionListener) getActivity();
+
+        NavController controller = ((TemplatesActivity) requireActivity()).getNavController();
+        final ViewModelStoreOwner viewModelStoreOwner =
+                controller.getViewModelStoreOwner(R.id.template_list_navigation);
+        mViewModel = new ViewModelProvider(viewModelStoreOwner).get(TemplateDetailsViewModel.class);
+        mViewModel.setDefaultTemplateName(getString(R.string.unnamed_template));
+        Logger.debug("flow", "PatternDetailsFragment.onCreateView(): model=" + mViewModel);
+
+        b = TemplateDetailsFragmentBinding.inflate(inflater);
+        Context context = b.patternDetailsRecyclerView.getContext();
+        if (mColumnCount <= 1) {
+            b.patternDetailsRecyclerView.setLayoutManager(new LinearLayoutManager(context));
+        }
+        else {
+            b.patternDetailsRecyclerView.setLayoutManager(
+                    new GridLayoutManager(context, mColumnCount));
+        }
+
+
+        TemplateDetailsAdapter adapter = new TemplateDetailsAdapter(mViewModel);
+        b.patternDetailsRecyclerView.setAdapter(adapter);
+        mViewModel.getItems(mPatternId)
+                  .observe(getViewLifecycleOwner(), adapter::setItems);
+
+        FragmentActivity activity = requireActivity();
+        if (activity instanceof FabManager.FabHandler)
+            FabManager.handle((FabManager.FabHandler) activity, b.patternDetailsRecyclerView);
+
+        return b.getRoot();
+    }
+    interface InteractionListener {
+        void onDeleteTemplate(@NonNull Long templateId);
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/templates/TemplateDetailsViewModel.java b/app/src/main/java/net/ktnx/mobileledger/ui/templates/TemplateDetailsViewModel.java
new file mode 100644 (file)
index 0000000..414340c
--- /dev/null
@@ -0,0 +1,338 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui.templates;
+
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
+import androidx.lifecycle.Observer;
+import androidx.lifecycle.ViewModel;
+
+import net.ktnx.mobileledger.BuildConfig;
+import net.ktnx.mobileledger.dao.BaseDAO;
+import net.ktnx.mobileledger.dao.TemplateAccountDAO;
+import net.ktnx.mobileledger.dao.TemplateHeaderDAO;
+import net.ktnx.mobileledger.db.DB;
+import net.ktnx.mobileledger.db.TemplateAccount;
+import net.ktnx.mobileledger.db.TemplateHeader;
+import net.ktnx.mobileledger.db.TemplateWithAccounts;
+import net.ktnx.mobileledger.model.TemplateDetailsItem;
+import net.ktnx.mobileledger.utils.Logger;
+import net.ktnx.mobileledger.utils.Misc;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicInteger;
+
+public class TemplateDetailsViewModel extends ViewModel {
+    static final String TAG = "template-details-model";
+    static final String DB_TAG = TAG + "-db";
+    private final MutableLiveData<List<TemplateDetailsItem>> items =
+            new MutableLiveData<>(Collections.emptyList());
+    private final AtomicInteger syntheticItemId = new AtomicInteger(0);
+    private Long mPatternId;
+    private String mDefaultTemplateName;
+    private boolean itemsLoaded = false;
+    public String getDefaultTemplateName() {
+        return mDefaultTemplateName;
+    }
+    public void setDefaultTemplateName(String name) {
+        mDefaultTemplateName = name;
+    }
+
+    public void resetItems() {
+        applyList(new ArrayList<>());
+    }
+    public void applyList(List<TemplateDetailsItem> srcList) {
+        applyList(srcList, false);
+    }
+    public void applyList(List<TemplateDetailsItem> srcList, boolean async) {
+        boolean changes;
+        if (srcList == null) {
+            srcList = new ArrayList<>(items.getValue());
+            changes = false;
+        }
+        else
+            changes = true;
+
+        srcList = Collections.unmodifiableList(srcList);
+
+        if (BuildConfig.DEBUG) {
+            Logger.debug(TAG, "Considering old list");
+            for (TemplateDetailsItem item : srcList)
+                Logger.debug(TAG, String.format(Locale.US, " id %d pos %d", item.getId(),
+                        item.getPosition()));
+        }
+
+        ArrayList<TemplateDetailsItem> newList = new ArrayList<>();
+
+        boolean hasEmptyItem = false;
+
+        if (srcList.size() < 1) {
+            final TemplateDetailsItem.Header header = TemplateDetailsItem.createHeader();
+            header.setId(0);
+            newList.add(header);
+            changes = true;
+        }
+        else {
+            newList.add(srcList.get(0));
+        }
+
+        for (int i = 1; i < srcList.size(); i++) {
+            final TemplateDetailsItem.AccountRow accRow = srcList.get(i)
+                                                                 .asAccountRowItem();
+            if (accRow.isEmpty()) {
+                // it is normal to have two empty rows if they are at the
+                // top (position 1 and 2)
+                if (!hasEmptyItem || i < 3) {
+                    accRow.setPosition(newList.size());
+                    newList.add(accRow);
+                }
+                else
+                    changes = true; // row skipped
+
+                hasEmptyItem = true;
+            }
+            else {
+                accRow.setPosition(newList.size());
+                newList.add(accRow);
+            }
+        }
+
+        while (newList.size() < 3) {
+            final TemplateDetailsItem.AccountRow accountRow =
+                    TemplateDetailsItem.createAccountRow();
+            accountRow.setId(genItemId());
+            accountRow.setPosition(newList.size());
+            newList.add(accountRow);
+            changes = true;
+            hasEmptyItem = true;
+        }
+
+        if (!hasEmptyItem) {
+            final TemplateDetailsItem.AccountRow accountRow =
+                    TemplateDetailsItem.createAccountRow();
+            accountRow.setId(genItemId());
+            accountRow.setPosition(newList.size());
+            newList.add(accountRow);
+            changes = true;
+        }
+
+        if (changes) {
+            Logger.debug(TAG, "Changes detected, applying new list");
+
+            if (async)
+                items.postValue(newList);
+            else
+                items.setValue(newList);
+        }
+        else
+            Logger.debug(TAG, "No changes, ignoring new list");
+    }
+    public int genItemId() {
+        return syntheticItemId.decrementAndGet();
+    }
+    public LiveData<List<TemplateDetailsItem>> getItems(Long patternId) {
+        if (itemsLoaded && Objects.equals(patternId, this.mPatternId))
+            return items;
+
+        if (patternId != null && patternId <= 0)
+            throw new IllegalArgumentException("Pattern ID " + patternId + " is invalid");
+
+        mPatternId = patternId;
+
+        if (mPatternId == null) {
+            resetItems();
+            itemsLoaded = true;
+            return items;
+        }
+
+        DB db = DB.get();
+        LiveData<TemplateWithAccounts> dbList = db.getTemplateDAO()
+                                                  .getTemplateWithAccounts(mPatternId);
+        Observer<TemplateWithAccounts> observer = new Observer<TemplateWithAccounts>() {
+            @Override
+            public void onChanged(TemplateWithAccounts src) {
+                ArrayList<TemplateDetailsItem> l = new ArrayList<>();
+
+                TemplateDetailsItem header = TemplateDetailsItem.fromRoomObject(src.header);
+                Logger.debug(DB_TAG, "Got header template item with id of " + header.getId());
+                l.add(header);
+                Collections.sort(src.accounts,
+                        (o1, o2) -> Long.compare(o1.getPosition(), o2.getPosition()));
+                for (TemplateAccount acc : src.accounts) {
+                    l.add(TemplateDetailsItem.fromRoomObject(acc));
+                }
+
+                for (TemplateDetailsItem i : l) {
+                    Logger.debug(DB_TAG, "Loaded pattern item " + i);
+                }
+                applyList(l, true);
+                itemsLoaded = true;
+
+                dbList.removeObserver(this);
+            }
+        };
+        dbList.observeForever(observer);
+
+        return items;
+    }
+    public void setTestText(String text) {
+        List<TemplateDetailsItem> list = new ArrayList<>(items.getValue());
+        TemplateDetailsItem.Header header = new TemplateDetailsItem.Header(list.get(0)
+                                                                               .asHeaderItem());
+        header.setTestText(text);
+        list.set(0, header);
+
+        items.setValue(list);
+    }
+    public void onSaveTemplate() {
+        Logger.debug("flow", "PatternDetailsViewModel.onSavePattern(); model=" + this);
+        final List<TemplateDetailsItem> list = Objects.requireNonNull(items.getValue());
+
+        BaseDAO.runAsync(() -> {
+            boolean newPattern = mPatternId == null || mPatternId <= 0;
+
+            TemplateDetailsItem.Header modelHeader = list.get(0)
+                                                         .asHeaderItem();
+
+            modelHeader.setName(Misc.trim(modelHeader.getName()));
+            if (modelHeader.getName()
+                           .isEmpty())
+                modelHeader.setName(getDefaultTemplateName());
+
+            TemplateHeaderDAO headerDAO = DB.get()
+                                            .getTemplateDAO();
+            TemplateHeader dbHeader = modelHeader.toDBO();
+            if (newPattern) {
+                dbHeader.setId(0L);
+                dbHeader.setId(mPatternId = headerDAO.insertSync(dbHeader));
+            }
+            else
+                headerDAO.updateSync(dbHeader);
+
+            Logger.debug("pattern-db",
+                    String.format(Locale.US, "Stored pattern header %d, item=%s", dbHeader.getId(),
+                            modelHeader));
+
+
+            TemplateAccountDAO taDAO = DB.get()
+                                         .getTemplateAccountDAO();
+            taDAO.prepareForSave(mPatternId);
+            for (int i = 1; i < list.size(); i++) {
+                final TemplateDetailsItem.AccountRow accRowItem = list.get(i)
+                                                                      .asAccountRowItem();
+                TemplateAccount dbAccount = accRowItem.toDBO(dbHeader.getId());
+                dbAccount.setTemplateId(mPatternId);
+                dbAccount.setPosition(i);
+                if (dbAccount.getId() < 0) {
+                    dbAccount.setId(0);
+                    dbAccount.setId(taDAO.insertSync(dbAccount));
+                }
+                else
+                    taDAO.updateSync(dbAccount);
+
+                Logger.debug("pattern-db", String.format(Locale.US,
+                        "Stored pattern account %d, account=%s, comment=%s, neg=%s, item=%s",
+                        dbAccount.getId(), dbAccount.getAccountName(),
+                        dbAccount.getAccountComment(), dbAccount.getNegateAmount(), accRowItem));
+            }
+            taDAO.finishSave(mPatternId);
+        });
+    }
+    private ArrayList<TemplateDetailsItem> copyItems() {
+        List<TemplateDetailsItem> oldList = items.getValue();
+
+        if (oldList == null)
+            return new ArrayList<>();
+
+        ArrayList<TemplateDetailsItem> result = new ArrayList<>(oldList.size());
+
+        for (TemplateDetailsItem item : oldList) {
+            if (item instanceof TemplateDetailsItem.Header)
+                result.add(new TemplateDetailsItem.Header(item.asHeaderItem()));
+            else if (item instanceof TemplateDetailsItem.AccountRow)
+                result.add(new TemplateDetailsItem.AccountRow(item.asAccountRowItem()));
+            else
+                throw new RuntimeException("Unexpected item " + item);
+        }
+
+        return result;
+    }
+    public void moveItem(int sourcePos, int targetPos) {
+        final List<TemplateDetailsItem> newList = copyItems();
+
+        if (BuildConfig.DEBUG) {
+            Logger.debug("drag", "Before move:");
+            for (int i = 1; i < newList.size(); i++) {
+                final TemplateDetailsItem item = newList.get(i);
+                Logger.debug("drag",
+                        String.format(Locale.US, "  %d: id %d, pos %d", i, item.getId(),
+                                item.getPosition()));
+            }
+        }
+
+        {
+            TemplateDetailsItem item = newList.remove(sourcePos);
+            newList.add(targetPos, item);
+        }
+
+        // adjust affected items' positions
+        {
+            int startPos, endPos;
+            if (sourcePos < targetPos) {
+                // moved down
+                startPos = sourcePos;
+                endPos = targetPos;
+            }
+            else {
+                // moved up
+                startPos = targetPos;
+                endPos = sourcePos;
+            }
+
+            for (int i = startPos; i <= endPos; i++) {
+                newList.get(i)
+                       .setPosition(i);
+            }
+        }
+
+        if (BuildConfig.DEBUG) {
+            Logger.debug("drag", "After move:");
+            for (int i = 1; i < newList.size(); i++) {
+                final TemplateDetailsItem item = newList.get(i);
+                Logger.debug("drag",
+                        String.format(Locale.US, "  %d: id %d, pos %d", i, item.getId(),
+                                item.getPosition()));
+            }
+        }
+
+        items.setValue(newList);
+    }
+    public void removeItem(int position) {
+        Logger.debug(TAG, "Removing item at position " + position);
+        ArrayList<TemplateDetailsItem> newList = copyItems();
+        newList.remove(position);
+        for (int i = position; i < newList.size(); i++)
+            newList.get(i)
+                   .setPosition(i);
+        applyList(newList);
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/templates/TemplateListDivider.java b/app/src/main/java/net/ktnx/mobileledger/ui/templates/TemplateListDivider.java
new file mode 100644 (file)
index 0000000..f841ad7
--- /dev/null
@@ -0,0 +1,159 @@
+/*
+ * Copyright © 2022 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui.templates;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.view.View;
+
+import androidx.recyclerview.widget.DividerItemDecoration;
+import androidx.recyclerview.widget.RecyclerView;
+
+import java.util.Objects;
+
+class TemplateListDivider extends DividerItemDecoration {
+    private final Rect mBounds = new Rect();
+    private int mOrientation;
+    public TemplateListDivider(Context context, int orientation) {
+        super(context, orientation);
+        mOrientation = orientation;
+    }
+    @Override
+    public void setOrientation(int orientation) {
+        super.setOrientation(orientation);
+        mOrientation = orientation;
+    }
+    @Override
+    public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
+        if (parent.getLayoutManager() == null || getDrawable() == null) {
+            return;
+        }
+        if (mOrientation == VERTICAL) {
+            drawVertical(c, parent);
+        }
+        else {
+            drawHorizontal(c, parent);
+        }
+    }
+
+    private void drawVertical(Canvas canvas, RecyclerView parent) {
+        canvas.save();
+        final int left;
+        final int right;
+        //noinspection AndroidLintNewApi - NewApi lint fails to handle overrides.
+        if (parent.getClipToPadding()) {
+            left = parent.getPaddingLeft();
+            right = parent.getWidth() - parent.getPaddingRight();
+            canvas.clipRect(left, parent.getPaddingTop(), right,
+                    parent.getHeight() - parent.getPaddingBottom());
+        }
+        else {
+            left = 0;
+            right = parent.getWidth();
+        }
+
+        final Drawable divider = Objects.requireNonNull(getDrawable());
+        final int childCount = parent.getChildCount();
+        final TemplatesRecyclerViewAdapter adapter =
+                (TemplatesRecyclerViewAdapter) Objects.requireNonNull(parent.getAdapter());
+        final int itemCount = adapter.getItemCount();
+        for (int i = 0; i < childCount; i++) {
+            final View child = parent.getChildAt(i);
+            final int childAdapterPosition = parent.getChildAdapterPosition(child);
+            if (childAdapterPosition == RecyclerView.NO_POSITION ||
+                adapter.getItemViewType(childAdapterPosition) ==
+                TemplatesRecyclerViewAdapter.ITEM_TYPE_DIVIDER ||
+                childAdapterPosition + 1 < itemCount &&
+                adapter.getItemViewType(childAdapterPosition + 1) ==
+                TemplatesRecyclerViewAdapter.ITEM_TYPE_DIVIDER)
+                continue;
+            parent.getDecoratedBoundsWithMargins(child, mBounds);
+            final int bottom = mBounds.bottom + Math.round(child.getTranslationY());
+            final int top = bottom - divider.getIntrinsicHeight();
+            divider.setBounds(left, top, right, bottom);
+            divider.draw(canvas);
+        }
+        canvas.restore();
+    }
+
+    private void drawHorizontal(Canvas canvas, RecyclerView parent) {
+        final RecyclerView.LayoutManager layoutManager = parent.getLayoutManager();
+        if (layoutManager == null)
+            return;
+
+        canvas.save();
+        final int top;
+        final int bottom;
+        //noinspection AndroidLintNewApi - NewApi lint fails to handle overrides.
+        if (parent.getClipToPadding()) {
+            top = parent.getPaddingTop();
+            bottom = parent.getHeight() - parent.getPaddingBottom();
+            canvas.clipRect(parent.getPaddingLeft(), top,
+                    parent.getWidth() - parent.getPaddingRight(), bottom);
+        }
+        else {
+            top = 0;
+            bottom = parent.getHeight();
+        }
+
+        final Drawable divider = Objects.requireNonNull(getDrawable());
+        final int childCount = parent.getChildCount();
+        final TemplatesRecyclerViewAdapter adapter =
+                (TemplatesRecyclerViewAdapter) Objects.requireNonNull(parent.getAdapter());
+        final int itemCount = adapter.getItemCount();
+        for (int i = 0; i < childCount; i++) {
+            final View child = parent.getChildAt(i);
+            final int childAdapterPosition = parent.getChildAdapterPosition(child);
+            if (childAdapterPosition == RecyclerView.NO_POSITION ||
+                adapter.getItemViewType(childAdapterPosition) ==
+                TemplatesRecyclerViewAdapter.ITEM_TYPE_DIVIDER ||
+                childAdapterPosition + 1 < itemCount &&
+                adapter.getItemViewType(childAdapterPosition + 1) ==
+                TemplatesRecyclerViewAdapter.ITEM_TYPE_DIVIDER)
+            {
+                continue;
+            }
+            layoutManager.getDecoratedBoundsWithMargins(child, mBounds);
+            final int right = mBounds.right + Math.round(child.getTranslationX());
+            final int left = right - divider.getIntrinsicWidth();
+            divider.setBounds(left, top, right, bottom);
+            divider.draw(canvas);
+        }
+        canvas.restore();
+    }
+    @Override
+    public void getItemOffsets(Rect outRect, View child, RecyclerView parent,
+                               RecyclerView.State state) {
+        final int childAdapterPosition = parent.getChildAdapterPosition(child);
+        final TemplatesRecyclerViewAdapter adapter =
+                (TemplatesRecyclerViewAdapter) Objects.requireNonNull(parent.getAdapter());
+        final int itemCount = adapter.getItemCount();
+
+        if (childAdapterPosition == RecyclerView.NO_POSITION ||
+            adapter.getItemViewType(childAdapterPosition) ==
+            TemplatesRecyclerViewAdapter.ITEM_TYPE_DIVIDER ||
+            childAdapterPosition + 1 < itemCount &&
+            adapter.getItemViewType(childAdapterPosition + 1) ==
+            TemplatesRecyclerViewAdapter.ITEM_TYPE_DIVIDER)
+            return;
+
+        super.getItemOffsets(outRect, child, parent, state);
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/templates/TemplateListFragment.java b/app/src/main/java/net/ktnx/mobileledger/ui/templates/TemplateListFragment.java
new file mode 100644 (file)
index 0000000..53eb604
--- /dev/null
@@ -0,0 +1,162 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui.templates;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentActivity;
+import androidx.lifecycle.Lifecycle;
+import androidx.lifecycle.LifecycleEventObserver;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.lifecycle.LiveData;
+import androidx.recyclerview.widget.DividerItemDecoration;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import net.ktnx.mobileledger.R;
+import net.ktnx.mobileledger.dao.TemplateHeaderDAO;
+import net.ktnx.mobileledger.databinding.FragmentTemplateListBinding;
+import net.ktnx.mobileledger.db.DB;
+import net.ktnx.mobileledger.db.TemplateHeader;
+import net.ktnx.mobileledger.ui.FabManager;
+import net.ktnx.mobileledger.ui.HelpDialog;
+import net.ktnx.mobileledger.utils.Logger;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.List;
+
+/**
+ * A simple {@link Fragment} subclass.
+ * Use the {@link TemplateListFragment#newInstance} factory method to
+ * create an instance of this fragment.
+ */
+public class TemplateListFragment extends Fragment {
+    private FragmentTemplateListBinding b;
+    private OnTemplateListFragmentInteractionListener mListener;
+    public TemplateListFragment() {
+        // Required empty public constructor
+    }
+    /**
+     * Use this factory method to create a new instance of
+     * this fragment using the provided parameters.
+     *
+     * @return A new instance of fragment TemplateListFragment.
+     */
+    public static TemplateListFragment newInstance() {
+        TemplateListFragment fragment = new TemplateListFragment();
+        Bundle args = new Bundle();
+        fragment.setArguments(args);
+        return fragment;
+    }
+    @Override
+    public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
+        super.onCreateOptionsMenu(menu, inflater);
+        inflater.inflate(R.menu.template_list_menu, menu);
+    }
+    @Override
+    public boolean onOptionsItemSelected(@NonNull MenuItem item) {
+        if (item.getItemId() == R.id.menu_item_template_list_help) {
+            HelpDialog.show(requireContext(), R.string.template_list_help_title,
+                    R.array.template_list_help_text);
+            return true;
+        }
+        return super.onOptionsItemSelected(item);
+    }
+    @Override
+    public void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setHasOptionsMenu(true);
+    }
+
+    @Override
+    public View onCreateView(@NotNull LayoutInflater inflater, ViewGroup container,
+                             Bundle savedInstanceState) {
+        Logger.debug("flow", "PatternListFragment.onCreateView()");
+        b = FragmentTemplateListBinding.inflate(inflater);
+        FragmentActivity activity = requireActivity();
+
+        if (activity instanceof FabManager.FabHandler)
+            FabManager.handle((FabManager.FabHandler) activity, b.templateList);
+
+        TemplatesRecyclerViewAdapter modelAdapter = new TemplatesRecyclerViewAdapter();
+
+        b.templateList.setAdapter(modelAdapter);
+        TemplateHeaderDAO pDao = DB.get()
+                                   .getTemplateDAO();
+        LiveData<List<TemplateHeader>> templates = pDao.getTemplates();
+        templates.observe(getViewLifecycleOwner(), modelAdapter::setTemplates);
+        LinearLayoutManager llm = new LinearLayoutManager(getContext());
+        llm.setOrientation(RecyclerView.VERTICAL);
+        b.templateList.setLayoutManager(llm);
+        DividerItemDecoration did =
+                new TemplateListDivider(activity, DividerItemDecoration.VERTICAL);
+        b.templateList.addItemDecoration(did);
+
+        return b.getRoot();
+    }
+    @Override
+    public void onAttach(@NonNull Context context) {
+        super.onAttach(context);
+        if (context instanceof OnTemplateListFragmentInteractionListener) {
+            mListener = (OnTemplateListFragmentInteractionListener) context;
+        }
+        else {
+            throw new RuntimeException(
+                    context.toString() + " must implement OnFragmentInteractionListener");
+        }
+
+        final LifecycleEventObserver observer = new LifecycleEventObserver() {
+            @Override
+            public void onStateChanged(@NonNull LifecycleOwner source,
+                                       @NonNull Lifecycle.Event event) {
+                if (event.getTargetState() == Lifecycle.State.CREATED) {
+//                    getActivity().setActionBar(b.toolbar);
+                    getLifecycle().removeObserver(this);
+                }
+            }
+        };
+        getLifecycle().addObserver(observer);
+    }
+    /**
+     * This interface must be implemented by activities that contain this
+     * fragment to allow an interaction in this fragment to be communicated
+     * to the activity and potentially other fragments contained in that
+     * activity.
+     * <p>
+     * See the Android Training lesson <a href=
+     * "http://developer.android.com/training/basics/fragments/communicating.html"
+     * >Communicating with Other Fragments</a> for more information.
+     */
+    public interface OnTemplateListFragmentInteractionListener {
+        void onSaveTemplate();
+
+        void onEditTemplate(Long id);
+
+        void onDuplicateTemplate(long id);
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/templates/TemplateViewHolder.java b/app/src/main/java/net/ktnx/mobileledger/ui/templates/TemplateViewHolder.java
new file mode 100644 (file)
index 0000000..aeb1dc1
--- /dev/null
@@ -0,0 +1,84 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui.templates;
+
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AlertDialog;
+import androidx.recyclerview.widget.RecyclerView;
+
+import net.ktnx.mobileledger.R;
+import net.ktnx.mobileledger.databinding.TemplateListTemplateItemBinding;
+import net.ktnx.mobileledger.databinding.TemplatesFallbackDividerBinding;
+import net.ktnx.mobileledger.db.TemplateHeader;
+
+abstract class BaseTemplateViewHolder extends RecyclerView.ViewHolder {
+    public BaseTemplateViewHolder(@NonNull View itemView) {
+        super(itemView);
+    }
+    abstract void bindToItem(TemplatesRecyclerViewAdapter.BaseTemplateItem item);
+    static class TemplateDividerViewHolder extends BaseTemplateViewHolder {
+        public TemplateDividerViewHolder(@NonNull TemplatesFallbackDividerBinding binding) {
+            super(binding.getRoot());
+        }
+        @Override
+        void bindToItem(TemplatesRecyclerViewAdapter.BaseTemplateItem item) {
+            // nothing
+        }
+    }
+
+    static class TemplateViewHolder extends BaseTemplateViewHolder {
+        final TemplateListTemplateItemBinding b;
+        public TemplateViewHolder(@NonNull TemplateListTemplateItemBinding binding) {
+            super(binding.getRoot());
+            b = binding;
+        }
+        @Override
+        public void bindToItem(TemplatesRecyclerViewAdapter.BaseTemplateItem baseItem) {
+            TemplateHeader item = ((TemplatesRecyclerViewAdapter.TemplateItem) baseItem).template;
+            b.templateName.setText(item.getName());
+            b.templateName.setOnClickListener(
+                    v -> ((TemplatesActivity) v.getContext()).onEditTemplate(item.getId()));
+            b.templateName.setOnLongClickListener((v) -> {
+                TemplatesActivity activity = (TemplatesActivity) v.getContext();
+                AlertDialog.Builder builder = new AlertDialog.Builder(activity);
+                final String templateName = item.getName();
+                builder.setTitle(templateName);
+                builder.setItems(R.array.templates_ctx_menu, (dialog, which) -> {
+                    if (which == 0) { // edit
+                        activity.onEditTemplate(item.getId());
+                    }
+                    else if (which == 1) { // duplicate
+                        activity.onDuplicateTemplate(item.getId());
+                    }
+                    else if (which == 2) { // delete
+                        activity.onDeleteTemplate(item.getId());
+                    }
+                    else {
+                        throw new RuntimeException(
+                                String.format("Unknown menu item id (%d)", which));
+                    }
+                    dialog.dismiss();
+                });
+                builder.show();
+                return true;
+            });
+        }
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/templates/TemplatesActivity.java b/app/src/main/java/net/ktnx/mobileledger/ui/templates/TemplatesActivity.java
new file mode 100644 (file)
index 0000000..3f9535c
--- /dev/null
@@ -0,0 +1,194 @@
+/*
+ * Copyright © 2022 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui.templates;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.view.MenuItem;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.ActionBar;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.lifecycle.ViewModelStoreOwner;
+import androidx.navigation.NavController;
+import androidx.navigation.NavDestination;
+import androidx.navigation.fragment.NavHostFragment;
+
+import com.google.android.material.snackbar.BaseTransientBottomBar;
+import com.google.android.material.snackbar.Snackbar;
+
+import net.ktnx.mobileledger.R;
+import net.ktnx.mobileledger.dao.TemplateHeaderDAO;
+import net.ktnx.mobileledger.databinding.ActivityTemplatesBinding;
+import net.ktnx.mobileledger.db.DB;
+import net.ktnx.mobileledger.db.TemplateWithAccounts;
+import net.ktnx.mobileledger.ui.FabManager;
+import net.ktnx.mobileledger.ui.QR;
+import net.ktnx.mobileledger.ui.activity.CrashReportingActivity;
+import net.ktnx.mobileledger.utils.Logger;
+
+import java.util.Objects;
+
+public class TemplatesActivity extends CrashReportingActivity
+        implements TemplateListFragment.OnTemplateListFragmentInteractionListener,
+        TemplateDetailsFragment.InteractionListener, QR.QRScanResultReceiver, QR.QRScanTrigger,
+        FabManager.FabHandler {
+    public static final String ARG_ADD_TEMPLATE = "add-template";
+    private ActivityTemplatesBinding b;
+    private NavController navController;
+    private ActivityResultLauncher<Void> qrScanLauncher;
+    private FabManager fabManager;
+    //    @Override
+//    public boolean onCreateOptionsMenu(Menu menu) {
+//        super.onCreateOptionsMenu(menu);
+//        getMenuInflater().inflate(R.menu.template_list_menu, menu);
+//
+//        return true;
+//    }
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        b = ActivityTemplatesBinding.inflate(getLayoutInflater());
+        setContentView(b.getRoot());
+        setSupportActionBar(b.toolbar);
+        // Show the Up button in the action bar.
+        ActionBar actionBar = getSupportActionBar();
+        if (actionBar != null) {
+            actionBar.setDisplayHomeAsUpEnabled(true);
+        }
+
+        NavHostFragment navHostFragment = (NavHostFragment) Objects.requireNonNull(
+                getSupportFragmentManager().findFragmentById(R.id.fragment_container));
+        navController = navHostFragment.getNavController();
+
+        navController.addOnDestinationChangedListener((controller, destination, arguments) -> {
+            if (destination.getId() == R.id.templateListFragment) {
+                b.toolbar.setTitle(getString(R.string.title_activity_templates));
+                b.fab.setImageResource(R.drawable.ic_add_white_24dp);
+            }
+            else {
+                b.fab.setImageResource(R.drawable.ic_save_white_24dp);
+            }
+        });
+
+        b.toolbar.setTitle(getString(R.string.title_activity_templates));
+
+        b.fab.setOnClickListener(v -> {
+            if (navController.getCurrentDestination()
+                             .getId() == R.id.templateListFragment)
+                onEditTemplate(null);
+            else
+                onSaveTemplate();
+        });
+
+        qrScanLauncher = QR.registerLauncher(this, this);
+
+        fabManager = new FabManager(b.fab);
+    }
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        if (item.getItemId() == android.R.id.home) {
+            final NavDestination currentDestination = navController.getCurrentDestination();
+            if (currentDestination != null &&
+                currentDestination.getId() == R.id.templateDetailsFragment)
+                navController.popBackStack();
+            else
+                finish();
+
+            return true;
+        }
+        return super.onOptionsItemSelected(item);
+    }
+    @Override
+    public void onDuplicateTemplate(long id) {
+        DB.get()
+          .getTemplateDAO()
+          .duplicateTemplateWithAccounts(id, null);
+    }
+    @Override
+    public void onEditTemplate(Long id) {
+        if (id == null) {
+            navController.navigate(R.id.action_templateListFragment_to_templateDetailsFragment);
+            b.toolbar.setTitle(getString(R.string.title_new_template));
+        }
+        else {
+            Bundle bundle = new Bundle();
+            bundle.putLong(TemplateDetailsFragment.ARG_TEMPLATE_ID, id);
+            navController.navigate(R.id.action_templateListFragment_to_templateDetailsFragment,
+                    bundle);
+            b.toolbar.setTitle(getString(R.string.title_edit_template));
+        }
+    }
+    @Override
+    public void onSaveTemplate() {
+        final ViewModelStoreOwner viewModelStoreOwner =
+                navController.getViewModelStoreOwner(R.id.template_list_navigation);
+        TemplateDetailsViewModel model =
+                new ViewModelProvider(viewModelStoreOwner).get(TemplateDetailsViewModel.class);
+        Logger.debug("flow", "TemplatesActivity.onSavePattern(): model=" + model);
+        model.onSaveTemplate();
+        navController.navigateUp();
+    }
+    public NavController getNavController() {
+        return navController;
+    }
+    @Override
+    public void onDeleteTemplate(@NonNull Long templateId) {
+        Objects.requireNonNull(templateId);
+        TemplateHeaderDAO dao = DB.get()
+                                  .getTemplateDAO();
+
+        dao.getTemplateWithAccountsAsync(templateId, template -> {
+            TemplateWithAccounts copy = TemplateWithAccounts.from(template);
+            dao.deleteAsync(template.header, () -> {
+                navController.popBackStack(R.id.templateListFragment, false);
+
+                Snackbar.make(b.getRoot(), String.format(
+                        TemplatesActivity.this.getString(R.string.template_xxx_deleted),
+                        template.header.getName()), BaseTransientBottomBar.LENGTH_LONG)
+                        .setAction(R.string.action_undo, v -> dao.insertAsync(copy, null))
+                        .show();
+            });
+        });
+    }
+    @Override
+    public void onQRScanResult(String scanned) {
+        Logger.debug("PatDet_fr", String.format("Got scanned text '%s'", scanned));
+        TemplateDetailsViewModel model = new ViewModelProvider(
+                navController.getViewModelStoreOwner(R.id.template_list_navigation)).get(
+                TemplateDetailsViewModel.class);
+        model.setTestText(scanned);
+    }
+    @Override
+    public void triggerQRScan() {
+        qrScanLauncher.launch(null);
+    }
+    @Override
+    public Context getContext() {
+        return this;
+    }
+    @Override
+    public void showManagedFab() {
+        fabManager.showFab();
+    }
+    @Override
+    public void hideManagedFab() {
+        fabManager.hideFab();
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/templates/TemplatesRecyclerViewAdapter.java b/app/src/main/java/net/ktnx/mobileledger/ui/templates/TemplatesRecyclerViewAdapter.java
new file mode 100644 (file)
index 0000000..0b1c827
--- /dev/null
@@ -0,0 +1,143 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui.templates;
+
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.AsyncListDiffer;
+import androidx.recyclerview.widget.DiffUtil;
+import androidx.recyclerview.widget.RecyclerView;
+
+import net.ktnx.mobileledger.databinding.TemplateListTemplateItemBinding;
+import net.ktnx.mobileledger.databinding.TemplatesFallbackDividerBinding;
+import net.ktnx.mobileledger.db.TemplateHeader;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class TemplatesRecyclerViewAdapter extends RecyclerView.Adapter<BaseTemplateViewHolder> {
+    static final int ITEM_TYPE_TEMPLATE = 1;
+    static final int ITEM_TYPE_DIVIDER = 2;
+    private final AsyncListDiffer<BaseTemplateItem> listDiffer;
+
+    public TemplatesRecyclerViewAdapter() {
+        listDiffer = new AsyncListDiffer<>(this, new DiffUtil.ItemCallback<BaseTemplateItem>() {
+            @Override
+            public boolean areItemsTheSame(
+                    @NotNull TemplatesRecyclerViewAdapter.BaseTemplateItem oldItem,
+                    @NotNull TemplatesRecyclerViewAdapter.BaseTemplateItem newItem) {
+                return oldItem.getId() == newItem.getId();
+            }
+            @Override
+            public boolean areContentsTheSame(
+                    @NotNull TemplatesRecyclerViewAdapter.BaseTemplateItem oldItem,
+                    @NotNull TemplatesRecyclerViewAdapter.BaseTemplateItem newItem) {
+                return oldItem.equals(newItem);
+            }
+        });
+    }
+    @NonNull
+    @Override
+    public BaseTemplateViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        final LayoutInflater inflater = LayoutInflater.from(parent.getContext());
+        switch (viewType) {
+            case ITEM_TYPE_TEMPLATE:
+                TemplateListTemplateItemBinding b =
+                        TemplateListTemplateItemBinding.inflate(inflater, parent, false);
+                return new BaseTemplateViewHolder.TemplateViewHolder(b);
+            case ITEM_TYPE_DIVIDER:
+                return new BaseTemplateViewHolder.TemplateDividerViewHolder(
+                        TemplatesFallbackDividerBinding.inflate(inflater, parent, false));
+            default:
+                throw new RuntimeException("Can't handle " + viewType);
+        }
+    }
+    @Override
+    public void onBindViewHolder(@NonNull BaseTemplateViewHolder holder, int position) {
+        holder.bindToItem(listDiffer.getCurrentList()
+                                    .get(position));
+    }
+    @Override
+    public int getItemViewType(int position) {
+        BaseTemplateItem item = getItem(position);
+        if (item instanceof TemplateItem)
+            return ITEM_TYPE_TEMPLATE;
+        if (item instanceof TemplateDivider)
+            return ITEM_TYPE_DIVIDER;
+
+        throw new RuntimeException("Can't handle " + item);
+    }
+    @Override
+    public int getItemCount() {
+        return listDiffer.getCurrentList()
+                         .size();
+    }
+    public void setTemplates(List<TemplateHeader> newList) {
+        List<BaseTemplateItem> itemList = new ArrayList<>();
+
+        boolean reachedFallbackItems = false;
+
+        for (TemplateHeader item : newList) {
+            if (!reachedFallbackItems && item.isFallback()) {
+                itemList.add(new TemplateDivider());
+                reachedFallbackItems = true;
+            }
+            itemList.add(new TemplateItem(item));
+        }
+
+        listDiffer.submitList(itemList);
+    }
+    public BaseTemplateItem getItem(int position) {
+        return listDiffer.getCurrentList()
+                         .get(position);
+    }
+
+    static abstract class BaseTemplateItem {
+        abstract long getId();
+
+        abstract boolean equals(BaseTemplateItem other);
+    }
+
+    static class TemplateItem extends BaseTemplateItem {
+        final TemplateHeader template;
+        TemplateItem(TemplateHeader template) {this.template = template;}
+        @Override
+        long getId() {
+            return template.getId();
+        }
+        @Override
+        boolean equals(BaseTemplateItem other) {
+            return template.equals(((TemplateItem) other).template);
+        }
+    }
+
+    static class TemplateDivider extends BaseTemplateItem {
+        @Override
+        long getId() {
+            return -1;
+        }
+        @Override
+        boolean equals(BaseTemplateItem other) {
+            return true;
+        }
+    }
+}
index ff84c36e5e2e8ba42db52cea1536408e9049f013..bc463ed564661bb6d3da11155158322bbb2b5b29 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2020 Damyan Ivanov.
+ * Copyright © 2021 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
 
 package net.ktnx.mobileledger.ui.transaction_list;
 
-import android.app.Activity;
-import android.content.Context;
-import android.database.sqlite.SQLiteDatabase;
-import android.graphics.Typeface;
-import android.os.AsyncTask;
-import android.text.Spannable;
-import android.text.SpannableString;
-import android.text.style.StyleSpan;
 import android.view.LayoutInflater;
-import android.view.View;
 import android.view.ViewGroup;
-import android.widget.LinearLayout;
-import android.widget.TextView;
 
-import androidx.annotation.ColorInt;
 import androidx.annotation.NonNull;
 import androidx.recyclerview.widget.AsyncListDiffer;
 import androidx.recyclerview.widget.DiffUtil;
 import androidx.recyclerview.widget.RecyclerView;
 
-import net.ktnx.mobileledger.App;
-import net.ktnx.mobileledger.R;
-import net.ktnx.mobileledger.model.Data;
-import net.ktnx.mobileledger.model.LedgerTransaction;
-import net.ktnx.mobileledger.model.LedgerTransactionAccount;
+import net.ktnx.mobileledger.databinding.LastUpdateLayoutBinding;
+import net.ktnx.mobileledger.databinding.TransactionDelimiterBinding;
+import net.ktnx.mobileledger.databinding.TransactionListRowBinding;
 import net.ktnx.mobileledger.model.TransactionListItem;
-import net.ktnx.mobileledger.ui.MainModel;
-import net.ktnx.mobileledger.utils.Colors;
-import net.ktnx.mobileledger.utils.Globals;
 import net.ktnx.mobileledger.utils.Logger;
 import net.ktnx.mobileledger.utils.Misc;
-import net.ktnx.mobileledger.utils.SimpleDate;
 
-import java.text.DateFormat;
-import java.util.GregorianCalendar;
 import java.util.List;
 import java.util.Locale;
-import java.util.TimeZone;
 
-public class TransactionListAdapter extends RecyclerView.Adapter<TransactionRowHolder> {
-    private final MainModel model;
+public class TransactionListAdapter extends RecyclerView.Adapter<TransactionRowHolderBase> {
     private final AsyncListDiffer<TransactionListItem> listDiffer;
-    public TransactionListAdapter(MainModel model) {
+    public TransactionListAdapter() {
         super();
-        this.model = model;
+
+        setHasStableIds(true);
 
         listDiffer = new AsyncListDiffer<>(this, new DiffUtil.ItemCallback<TransactionListItem>() {
             @Override
@@ -75,8 +54,8 @@ public class TransactionListAdapter extends RecyclerView.Adapter<TransactionRowH
                                        .equals(newItem.getDate()));
                     case TRANSACTION:
                         return oldItem.getTransaction()
-                                      .getId() == newItem.getTransaction()
-                                                         .getId();
+                                      .getLedgerId() == newItem.getTransaction()
+                                                               .getLedgerId();
                     case HEADER:
                         return true;    // there can be only one header
                     default:
@@ -90,11 +69,14 @@ public class TransactionListAdapter extends RecyclerView.Adapter<TransactionRowH
                                               @NonNull TransactionListItem newItem) {
                 switch (oldItem.getType()) {
                     case DELIMITER:
-                        // Delimiters items are "same" for same dates and the contents are the date
-                        return true;
+                        return oldItem.isMonthShown() == newItem.isMonthShown();
                     case TRANSACTION:
                         return oldItem.getTransaction()
-                                      .equals(newItem.getTransaction());
+                                      .equals(newItem.getTransaction()) &&
+                               Misc.equalStrings(oldItem.getBoldAccountName(),
+                                       newItem.getBoldAccountName()) &&
+                               Misc.equalStrings(oldItem.getRunningTotal(),
+                                       newItem.getRunningTotal());
                     case HEADER:
                         // headers don't differ in their contents. they observe the last update
                         // date and react to its changes
@@ -108,7 +90,32 @@ public class TransactionListAdapter extends RecyclerView.Adapter<TransactionRowH
             }
         });
     }
-    public void onBindViewHolder(@NonNull TransactionRowHolder holder, int position) {
+    @Override
+    public long getItemId(int position) {
+        TransactionListItem item = listDiffer.getCurrentList()
+                                             .get(position);
+        switch (item.getType()) {
+            case HEADER:
+                return -1;
+            case TRANSACTION:
+                return item.getTransaction()
+                           .getLedgerId();
+            case DELIMITER:
+                return -item.getDate()
+                            .toDate()
+                            .getTime();
+            default:
+                throw new IllegalStateException("Unexpected value: " + item.getType());
+        }
+    }
+    @Override
+    public int getItemViewType(int position) {
+        return listDiffer.getCurrentList()
+                         .get(position)
+                         .getType()
+                         .ordinal();
+    }
+    public void onBindViewHolder(@NonNull TransactionRowHolderBase holder, int position) {
         TransactionListItem item = listDiffer.getCurrentList()
                                              .get(position);
 
@@ -120,53 +127,20 @@ public class TransactionListAdapter extends RecyclerView.Adapter<TransactionRowH
             return;
 
         final TransactionListItem.Type newType = item.getType();
-        holder.setType(newType);
 
         switch (newType) {
             case TRANSACTION:
-                LedgerTransaction tr = item.getTransaction();
-
-                //        debug("transactions", String.format("Filling position %d with %d
-                //        accounts", position,
-                //                tr.getAccounts().size()));
+                holder.asTransaction()
+                      .bind(item, item.getBoldAccountName());
 
-                TransactionLoader loader = new TransactionLoader();
-                loader.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR,
-                        new TransactionLoaderParams(tr, holder, position, model.getAccountFilter()
-                                                                               .getValue()));
-
-                // WORKAROUND what seems to be a bug in CardHolder somewhere
-                // when a view that was previously holding a delimiter is re-purposed
-                // occasionally it stays too short (not high enough)
-                holder.vTransaction.measure(
-                        View.MeasureSpec.makeMeasureSpec(holder.itemView.getWidth(),
-                                View.MeasureSpec.EXACTLY),
-                        View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED));
                 break;
             case DELIMITER:
-                SimpleDate date = item.getDate();
-                holder.tvDelimiterDate.setText(DateFormat.getDateInstance()
-                                                         .format(date.toDate()));
-                if (item.isMonthShown()) {
-                    GregorianCalendar cal = new GregorianCalendar(TimeZone.getDefault());
-                    cal.setTime(date.toDate());
-                    App.prepareMonthNames();
-                    holder.tvDelimiterMonth.setText(
-                            Globals.monthNames[cal.get(GregorianCalendar.MONTH)]);
-                    holder.tvDelimiterMonth.setVisibility(View.VISIBLE);
-                    //                holder.vDelimiterLine.setBackgroundResource(R.drawable
-                    //                .dashed_border_8dp);
-                    holder.vDelimiterThick.setVisibility(View.VISIBLE);
-                }
-                else {
-                    holder.tvDelimiterMonth.setVisibility(View.GONE);
-                    //                holder.vDelimiterLine.setBackgroundResource(R.drawable
-                    //                .dashed_border_1dp);
-                    holder.vDelimiterThick.setVisibility(View.GONE);
-                }
+                holder.asDelimiter()
+                      .bind(item);
                 break;
             case HEADER:
-                holder.setLastUpdateText(Data.lastTransactionsUpdateText.get());
+                holder.asHeader()
+                      .bind();
 
                 break;
             default:
@@ -175,11 +149,23 @@ public class TransactionListAdapter extends RecyclerView.Adapter<TransactionRowH
     }
     @NonNull
     @Override
-    public TransactionRowHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+    public TransactionRowHolderBase onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
 //        debug("perf", "onCreateViewHolder called");
-        View row = LayoutInflater.from(parent.getContext())
-                                 .inflate(R.layout.transaction_list_row, parent, false);
-        return new TransactionRowHolder(row);
+        final LayoutInflater inflater = LayoutInflater.from(parent.getContext());
+
+        switch (TransactionListItem.Type.valueOf(viewType)) {
+            case TRANSACTION:
+                return new TransactionRowHolder(
+                        TransactionListRowBinding.inflate(inflater, parent, false));
+            case DELIMITER:
+                return new TransactionListDelimiterRowHolder(
+                        TransactionDelimiterBinding.inflate(inflater, parent, false));
+            case HEADER:
+                return new TransactionListLastUpdateRowHolder(
+                        LastUpdateLayoutBinding.inflate(inflater, parent, false));
+            default:
+                throw new IllegalStateException("Unexpected value: " + viewType);
+        }
     }
 
     @Override
@@ -192,132 +178,4 @@ public class TransactionListAdapter extends RecyclerView.Adapter<TransactionRowH
                 String.format(Locale.US, "Got new transaction list (%d items)", newList.size()));
         listDiffer.submitList(newList);
     }
-    enum LoaderStep {HEAD, ACCOUNTS, DONE}
-
-    private static class TransactionLoader
-            extends AsyncTask<TransactionLoaderParams, TransactionLoaderStep, Void> {
-        @Override
-        protected Void doInBackground(TransactionLoaderParams... p) {
-            LedgerTransaction tr = p[0].transaction;
-
-            SQLiteDatabase db = App.getDatabase();
-            tr.loadData(db);
-
-            publishProgress(new TransactionLoaderStep(p[0].holder, p[0].position, tr));
-
-            int rowIndex = 0;
-            // FIXME ConcurrentModificationException in ArrayList$ltr.next (ArrayList.java:831)
-            for (LedgerTransactionAccount acc : tr.getAccounts()) {
-//                debug(c.getAccountName(), acc.getAmount()));
-                publishProgress(new TransactionLoaderStep(p[0].holder, acc, rowIndex++,
-                        p[0].boldAccountName));
-            }
-
-            publishProgress(new TransactionLoaderStep(p[0].holder, p[0].position, rowIndex));
-
-            return null;
-        }
-        @Override
-        protected void onProgressUpdate(TransactionLoaderStep... values) {
-            super.onProgressUpdate(values);
-            TransactionLoaderStep step = values[0];
-            TransactionRowHolder holder = step.getHolder();
-
-            switch (step.getStep()) {
-                case HEAD:
-                    holder.tvDescription.setText(step.getTransaction()
-                                                     .getDescription());
-                    String trComment = Misc.emptyIsNull(step.getTransaction()
-                                                            .getComment());
-                    if (trComment == null)
-                        holder.tvComment.setVisibility(View.GONE);
-                    else {
-                        holder.tvComment.setText(trComment);
-                        holder.tvComment.setVisibility(View.VISIBLE);
-                    }
-
-//                    if (step.isOdd())
-//                        holder.row.setBackgroundColor(Colors.tableRowDarkBG);
-//                    else
-//                        holder.row.setBackgroundColor(Colors.tableRowLightBG);
-
-                    break;
-                case ACCOUNTS:
-                    int rowIndex = step.getAccountPosition();
-                    Context ctx = holder.row.getContext();
-                    LinearLayout row = (LinearLayout) holder.tableAccounts.getChildAt(rowIndex);
-                    if (row == null) {
-                        row = new LinearLayout(ctx);
-                        LayoutInflater inflater = ((Activity) ctx).getLayoutInflater();
-                        inflater.inflate(R.layout.transaction_list_row_accounts_table_row, row);
-                        holder.tableAccounts.addView(row);
-                    }
-                    TextView dummyText = row.findViewById(R.id.dummy_text);
-                    TextView accName = row.findViewById(R.id.transaction_list_acc_row_acc_name);
-                    TextView accComment =
-                            row.findViewById(R.id.transaction_list_acc_row_acc_comment);
-                    TextView accAmount = row.findViewById(R.id.transaction_list_acc_row_acc_amount);
-                    LedgerTransactionAccount acc = step.getAccount();
-
-
-//                    debug("tmp", String.format("showing acc row %d: %s %1.2f", rowIndex,
-//                            acc.getAccountName(), acc.getAmount()));
-
-                    String boldAccountName = step.getBoldAccountName();
-                    if ((boldAccountName != null) && acc.getAccountName()
-                                                        .startsWith(boldAccountName))
-                    {
-                        accName.setTextColor(Colors.secondary);
-                        accAmount.setTextColor(Colors.secondary);
-
-                        SpannableString ss = new SpannableString(acc.getAccountName());
-                        ss.setSpan(new StyleSpan(Typeface.BOLD), 0, boldAccountName.length(),
-                                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
-                        accName.setText(ss);
-                    }
-                    else {
-                        @ColorInt int textColor = dummyText.getTextColors()
-                                                           .getDefaultColor();
-                        accName.setTextColor(textColor);
-                        accAmount.setTextColor(textColor);
-                        accName.setText(acc.getAccountName());
-                    }
-
-                    String comment = acc.getComment();
-                    if (comment != null && !comment.isEmpty()) {
-                        accComment.setText(comment);
-                        accComment.setVisibility(View.VISIBLE);
-                    }
-                    else {
-                        accComment.setVisibility(View.GONE);
-                    }
-                    accAmount.setText(acc.toString());
-
-                    break;
-                case DONE:
-                    int accCount = step.getAccountCount();
-                    if (holder.tableAccounts.getChildCount() > accCount) {
-                        holder.tableAccounts.removeViews(accCount,
-                                holder.tableAccounts.getChildCount() - accCount);
-                    }
-
-//                    debug("transactions",
-//                            String.format("Position %d fill done", step.getPosition()));
-            }
-        }
-    }
-
-    private static class TransactionLoaderParams {
-        final LedgerTransaction transaction;
-        final TransactionRowHolder holder;
-        final int position;
-        final String boldAccountName;
-        TransactionLoaderParams(LedgerTransaction transaction, TransactionRowHolder holder,
-                                int position, String boldAccountName) {
-            this.transaction = transaction;
-            this.holder = holder;
-            this.position = position;
-            this.boldAccountName = boldAccountName;
-        }
-    }
 }
\ No newline at end of file
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/transaction_list/TransactionListDelimiterRowHolder.java b/app/src/main/java/net/ktnx/mobileledger/ui/transaction_list/TransactionListDelimiterRowHolder.java
new file mode 100644 (file)
index 0000000..b5fe883
--- /dev/null
@@ -0,0 +1,70 @@
+/*
+ * Copyright © 2024 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui.transaction_list;
+
+import android.view.View;
+
+import androidx.constraintlayout.widget.ConstraintLayout;
+
+import net.ktnx.mobileledger.App;
+import net.ktnx.mobileledger.databinding.TransactionDelimiterBinding;
+import net.ktnx.mobileledger.model.TransactionListItem;
+import net.ktnx.mobileledger.utils.DimensionUtils;
+import net.ktnx.mobileledger.utils.Globals;
+import net.ktnx.mobileledger.utils.SimpleDate;
+
+import java.text.DateFormat;
+import java.util.GregorianCalendar;
+import java.util.TimeZone;
+
+class TransactionListDelimiterRowHolder extends TransactionRowHolderBase {
+    private final TransactionDelimiterBinding b;
+    TransactionListDelimiterRowHolder(TransactionDelimiterBinding binding) {
+        super(binding.getRoot());
+        b = binding;
+    }
+    public void bind(TransactionListItem item) {
+        SimpleDate date = item.getDate();
+        b.transactionDelimiterDate.setText(DateFormat.getDateInstance()
+                                                     .format(date.toDate()));
+        if (item.isMonthShown()) {
+            GregorianCalendar cal = new GregorianCalendar(TimeZone.getDefault());
+            cal.setTime(date.toDate());
+            App.prepareMonthNames();
+            b.transactionDelimiterMonth.setText(
+                    Globals.monthNames[cal.get(GregorianCalendar.MONTH)]);
+            b.transactionDelimiterMonth.setVisibility(View.VISIBLE);
+            b.transactionDelimiterThick.setVisibility(View.VISIBLE);
+            ConstraintLayout.LayoutParams lp =
+                    (ConstraintLayout.LayoutParams) b.transactionDelimiterThick.getLayoutParams();
+            lp.height = DimensionUtils.dp2px(b.getRoot()
+                                              .getContext(), 4);
+            b.transactionDelimiterThick.setLayoutParams(lp);
+        }
+        else {
+            b.transactionDelimiterMonth.setVisibility(View.GONE);
+            ConstraintLayout.LayoutParams lp =
+                    (ConstraintLayout.LayoutParams) b.transactionDelimiterThick.getLayoutParams();
+            lp.height = DimensionUtils.dp2px(b.getRoot()
+                                              .getContext(), 1.3f);
+            b.transactionDelimiterThick.setLayoutParams(lp);
+            b.transactionDelimiterThick.setVisibility(View.VISIBLE);
+        }
+
+    }
+}
index fdb54849f7de0b351a190b537ab80db7b313bb58..a31b657fdc52b106e6ffd327e040b0d9cc3118e1 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2020 Damyan Ivanov.
+ * Copyright © 2021 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
@@ -17,8 +17,6 @@
 
 package net.ktnx.mobileledger.ui.transaction_list;
 
-import android.database.Cursor;
-import android.os.AsyncTask;
 import android.os.Bundle;
 import android.view.LayoutInflater;
 import android.view.Menu;
@@ -27,26 +25,28 @@ import android.view.MenuItem;
 import android.view.View;
 import android.view.ViewGroup;
 import android.view.inputmethod.InputMethodManager;
-import android.widget.AutoCompleteTextView;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.lifecycle.ViewModelProvider;
 import androidx.recyclerview.widget.LinearLayoutManager;
 import androidx.recyclerview.widget.RecyclerView;
+import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
 
 import net.ktnx.mobileledger.R;
 import net.ktnx.mobileledger.async.TransactionDateFinder;
+import net.ktnx.mobileledger.databinding.TransactionListFragmentBinding;
+import net.ktnx.mobileledger.db.AccountAutocompleteAdapter;
+import net.ktnx.mobileledger.db.Profile;
 import net.ktnx.mobileledger.model.Data;
-import net.ktnx.mobileledger.model.MobileLedgerProfile;
 import net.ktnx.mobileledger.ui.DatePickerFragment;
+import net.ktnx.mobileledger.ui.FabManager;
 import net.ktnx.mobileledger.ui.MainModel;
 import net.ktnx.mobileledger.ui.MobileLedgerListFragment;
 import net.ktnx.mobileledger.ui.activity.MainActivity;
 import net.ktnx.mobileledger.utils.Colors;
 import net.ktnx.mobileledger.utils.Globals;
 import net.ktnx.mobileledger.utils.Logger;
-import net.ktnx.mobileledger.utils.MLDB;
 import net.ktnx.mobileledger.utils.SimpleDate;
 
 import org.jetbrains.annotations.NotNull;
@@ -59,9 +59,10 @@ import static net.ktnx.mobileledger.utils.Logger.debug;
 public class TransactionListFragment extends MobileLedgerListFragment
         implements DatePickerFragment.DatePickedListener {
     private MenuItem menuTransactionListFilter;
-    private View vAccountFilter;
-    private AutoCompleteTextView accNameFilter;
+    private MenuItem menuGoToDate;
     private MainModel model;
+    private boolean fragmentActive = false;
+    private TransactionListFragmentBinding b;
     @Override
     public void onCreate(@Nullable Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
@@ -71,28 +72,47 @@ public class TransactionListFragment extends MobileLedgerListFragment
     @Override
     public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
                              @Nullable Bundle savedInstanceState) {
-        return inflater.inflate(R.layout.transaction_list_fragment, container, false);
+        b = TransactionListFragmentBinding.inflate(inflater, container, false);
+        return b.getRoot();
     }
     @Override
     public void onResume() {
         super.onResume();
+        fragmentActive = true;
+        toggleMenuItems();
         debug("flow", "TransactionListFragment.onResume()");
     }
+    private void toggleMenuItems() {
+        if (menuGoToDate != null)
+            menuGoToDate.setVisible(fragmentActive);
+        if (menuTransactionListFilter != null) {
+            final int filterVisibility = b.transactionListAccountNameFilter.getVisibility();
+            menuTransactionListFilter.setVisible(
+                    fragmentActive && filterVisibility != View.VISIBLE);
+        }
+    }
     @Override
     public void onStop() {
         super.onStop();
+        fragmentActive = false;
+        toggleMenuItems();
         debug("flow", "TransactionListFragment.onStop()");
     }
     @Override
     public void onPause() {
         super.onPause();
+        fragmentActive = false;
+        toggleMenuItems();
         debug("flow", "TransactionListFragment.onPause()");
     }
     @Override
-    public void onActivityCreated(@Nullable Bundle savedInstanceState) {
+    public SwipeRefreshLayout getRefreshLayout() {
+        return b.transactionSwipe;
+    }
+    @Override
+    public void onViewCreated(@NotNull View view, @Nullable Bundle savedInstanceState) {
         debug("flow", "TransactionListFragment.onActivityCreated called");
-        super.onActivityCreated(savedInstanceState);
-
+        super.onViewCreated(view, savedInstanceState);
         Data.backgroundTasksRunning.observe(getViewLifecycleOwner(),
                 this::onBackgroundTaskRunningChanged);
 
@@ -100,40 +120,33 @@ public class TransactionListFragment extends MobileLedgerListFragment
 
         model = new ViewModelProvider(requireActivity()).get(MainModel.class);
 
-        refreshLayout = mainActivity.findViewById(R.id.transaction_swipe);
-        if (refreshLayout == null)
-            throw new RuntimeException("Can't get hold on the swipe layout");
-        root = mainActivity.findViewById(R.id.transaction_root);
-        if (root == null)
-            throw new RuntimeException("Can't get hold on the transaction value view");
-        modelAdapter = new TransactionListAdapter(model);
-        root.setAdapter(modelAdapter);
+        modelAdapter = new TransactionListAdapter();
+        b.transactionRoot.setAdapter(modelAdapter);
 
         mainActivity.fabShouldShow();
 
-        manageFabOnScroll();
+        if (mainActivity instanceof FabManager.FabHandler)
+            FabManager.handle(mainActivity, b.transactionRoot);
 
         LinearLayoutManager llm = new LinearLayoutManager(mainActivity);
 
         llm.setOrientation(RecyclerView.VERTICAL);
-        root.setLayoutManager(llm);
+        b.transactionRoot.setLayoutManager(llm);
 
-        refreshLayout.setOnRefreshListener(() -> {
+        b.transactionSwipe.setOnRefreshListener(() -> {
             debug("ui", "refreshing transactions via swipe");
             model.scheduleTransactionListRetrieval();
         });
 
         Colors.themeWatch.observe(getViewLifecycleOwner(), this::themeChanged);
 
-        vAccountFilter = mainActivity.findViewById(R.id.transaction_list_account_name_filter);
-        accNameFilter = mainActivity.findViewById(R.id.transaction_filter_account_name);
+        Data.observeProfile(getViewLifecycleOwner(), this::onProfileChanged);
 
-        MLDB.hookAutocompletionAdapter(mainActivity, accNameFilter, "accounts", "name");
-        accNameFilter.setOnItemClickListener((parent, view, position, id) -> {
+        b.transactionFilterAccountName.setOnItemClickListener((parent, v, position, id) -> {
 //                debug("tmp", "direct onItemClick");
-            Cursor c = (Cursor) parent.getItemAtPosition(position);
             model.getAccountFilter()
-                 .setValue(c.getString(1));
+                 .setValue(parent.getItemAtPosition(position)
+                                 .toString());
             Globals.hideSoftKeyboard(mainActivity);
         });
 
@@ -141,47 +154,40 @@ public class TransactionListFragment extends MobileLedgerListFragment
              .observe(getViewLifecycleOwner(), this::onAccountNameFilterChanged);
 
         model.getUpdatingFlag()
-             .observe(getViewLifecycleOwner(), (flag) -> refreshLayout.setRefreshing(flag));
-        MobileLedgerProfile profile = Data.getProfile();
+             .observe(getViewLifecycleOwner(), (flag) -> b.transactionSwipe.setRefreshing(flag));
         model.getDisplayedTransactions()
              .observe(getViewLifecycleOwner(), list -> modelAdapter.setTransactions(list));
 
-        mainActivity.findViewById(R.id.clearAccountNameFilter)
-                    .setOnClickListener(v -> {
-                        model.getAccountFilter()
-                             .setValue(null);
-                        vAccountFilter.setVisibility(View.GONE);
-                        menuTransactionListFilter.setVisible(true);
-                        Globals.hideSoftKeyboard(mainActivity);
-                    });
+        view.findViewById(R.id.clearAccountNameFilter)
+            .setOnClickListener(v -> {
+                model.getAccountFilter()
+                     .setValue(null);
+                Globals.hideSoftKeyboard(mainActivity);
+            });
 
         model.foundTransactionItemIndex.observe(getViewLifecycleOwner(), pos -> {
             Logger.debug("go-to-date", String.format(Locale.US, "Found pos %d", pos));
             if (pos != null) {
-                root.scrollToPosition(pos);
+                b.transactionRoot.scrollToPosition(pos);
                 // reset the value to avoid re-notification upon reconfiguration or app restart
                 model.foundTransactionItemIndex.setValue(null);
             }
         });
     }
-    private void onAccountNameFilterChanged(String accName) {
-        final String fieldText = accNameFilter.getText()
-                                              .toString();
-        if ((accName == null) && (fieldText.equals("")))
+    private void onProfileChanged(Profile profile) {
+        if (profile == null)
             return;
 
-        if (accNameFilter != null) {
-            accNameFilter.setText(accName, false);
-        }
-        final boolean filterActive = (accName != null) && !accName.isEmpty();
-        if (vAccountFilter != null) {
-            vAccountFilter.setVisibility(filterActive ? View.VISIBLE : View.GONE);
-        }
+        b.transactionFilterAccountName.setAdapter(
+                new AccountAutocompleteAdapter(getContext(), profile));
+    }
+    private void onAccountNameFilterChanged(String accName) {
+        b.transactionFilterAccountName.setText(accName, false);
+
+        boolean filterActive = (accName != null) && !accName.isEmpty();
+        b.transactionListAccountNameFilter.setVisibility(filterActive ? View.VISIBLE : View.GONE);
         if (menuTransactionListFilter != null)
             menuTransactionListFilter.setVisible(!filterActive);
-
-        model.scheduleTransactionListReload();
-
     }
     @Override
     public void onCreateOptionsMenu(@NotNull Menu menu, @NotNull MenuInflater inflater) {
@@ -190,43 +196,41 @@ public class TransactionListFragment extends MobileLedgerListFragment
         menuTransactionListFilter = menu.findItem(R.id.menu_transaction_list_filter);
         if ((menuTransactionListFilter == null))
             throw new AssertionError();
+        menuGoToDate = menu.findItem(R.id.menu_go_to_date);
+        if ((menuGoToDate == null))
+            throw new AssertionError();
 
-        if ((model.getAccountFilter()
-                  .getValue() != null) || (vAccountFilter.getVisibility() == View.VISIBLE))
-        {
-            menuTransactionListFilter.setVisible(false);
-        }
+        model.getAccountFilter()
+             .observe(this, v -> menuTransactionListFilter.setVisible(v == null));
 
         super.onCreateOptionsMenu(menu, inflater);
 
         menuTransactionListFilter.setOnMenuItemClickListener(item -> {
-            vAccountFilter.setVisibility(View.VISIBLE);
-            if (menuTransactionListFilter != null)
-                menuTransactionListFilter.setVisible(false);
-            accNameFilter.requestFocus();
+            b.transactionListAccountNameFilter.setVisibility(View.VISIBLE);
+            menuTransactionListFilter.setVisible(false);
+            b.transactionFilterAccountName.requestFocus();
             InputMethodManager imm =
                     (InputMethodManager) getMainActivity().getSystemService(INPUT_METHOD_SERVICE);
-            imm.showSoftInput(accNameFilter, 0);
+            imm.showSoftInput(b.transactionFilterAccountName, 0);
 
             return true;
         });
 
-        menu.findItem(R.id.menu_go_to_date)
-            .setOnMenuItemClickListener(item -> {
-                DatePickerFragment picker = new DatePickerFragment();
-                picker.setOnDatePickedListener(this);
-                picker.setDateRange(model.getFirstTransactionDate(),
-                        model.getLastTransactionDate());
-                picker.show(requireActivity().getSupportFragmentManager(), null);
-                return true;
-            });
+        menuGoToDate.setOnMenuItemClickListener(item -> {
+            DatePickerFragment picker = new DatePickerFragment();
+            picker.setOnDatePickedListener(this);
+            picker.setDateRange(model.getFirstTransactionDate(), model.getLastTransactionDate());
+            picker.show(requireActivity().getSupportFragmentManager(), null);
+            return true;
+        });
+
+        toggleMenuItems();
     }
     @Override
     public void onDatePicked(int year, int month, int day) {
         RecyclerView list = requireActivity().findViewById(R.id.transaction_root);
-        AsyncTask<TransactionDateFinder.Params, Void, Integer> finder = new TransactionDateFinder();
+        TransactionDateFinder finder = new TransactionDateFinder(model, new SimpleDate(year, month + 1, day));
 
-        finder.execute(
-                new TransactionDateFinder.Params(model, new SimpleDate(year, month + 1, day)));
+        finder.start();
     }
 }
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/transaction_list/TransactionListLastUpdateRowHolder.java b/app/src/main/java/net/ktnx/mobileledger/ui/transaction_list/TransactionListLastUpdateRowHolder.java
new file mode 100644 (file)
index 0000000..23382cc
--- /dev/null
@@ -0,0 +1,37 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui.transaction_list;
+
+import net.ktnx.mobileledger.databinding.LastUpdateLayoutBinding;
+import net.ktnx.mobileledger.model.Data;
+import net.ktnx.mobileledger.ui.activity.MainActivity;
+
+class TransactionListLastUpdateRowHolder extends TransactionRowHolderBase {
+    private final LastUpdateLayoutBinding b;
+    TransactionListLastUpdateRowHolder(LastUpdateLayoutBinding binding) {
+        super(binding.getRoot());
+        b = binding;
+    }
+    void setLastUpdateText(String text) {
+        b.lastUpdateText.setText(text);
+    }
+    public void bind() {
+        Data.lastTransactionsUpdateText.observe((MainActivity) b.lastUpdateText.getContext(),
+                b.lastUpdateText::setText);
+    }
+}
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/transaction_list/TransactionLoaderStep.java b/app/src/main/java/net/ktnx/mobileledger/ui/transaction_list/TransactionLoaderStep.java
deleted file mode 100644 (file)
index 8fcb6de..0000000
+++ /dev/null
@@ -1,77 +0,0 @@
-/*
- * Copyright © 2020 Damyan Ivanov.
- * This file is part of MoLe.
- * MoLe is free software: you can distribute it and/or modify it
- * under the term of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your opinion), any later version.
- *
- * MoLe is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License terms for details.
- *
- * You should have received a copy of the GNU General Public License
- * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package net.ktnx.mobileledger.ui.transaction_list;
-
-import net.ktnx.mobileledger.model.LedgerTransaction;
-import net.ktnx.mobileledger.model.LedgerTransactionAccount;
-
-class TransactionLoaderStep {
-    private final TransactionListAdapter.LoaderStep step;
-    private final TransactionRowHolder holder;
-    private int position;
-    private int accountCount;
-    private LedgerTransaction transaction;
-    private LedgerTransactionAccount account;
-    private int accountPosition;
-    private String boldAccountName;
-    public TransactionLoaderStep(TransactionRowHolder holder, int position,
-                                 LedgerTransaction transaction) {
-        this.step = TransactionListAdapter.LoaderStep.HEAD;
-        this.holder = holder;
-        this.transaction = transaction;
-        this.position = position;
-    }
-    public TransactionLoaderStep(TransactionRowHolder holder, LedgerTransactionAccount account,
-                                 int accountPosition, String boldAccountName) {
-        this.step = TransactionListAdapter.LoaderStep.ACCOUNTS;
-        this.holder = holder;
-        this.account = account;
-        this.accountPosition = accountPosition;
-        this.boldAccountName = boldAccountName;
-    }
-    public TransactionLoaderStep(TransactionRowHolder holder, int position, int accountCount) {
-        this.step = TransactionListAdapter.LoaderStep.DONE;
-        this.holder = holder;
-        this.position = position;
-        this.accountCount = accountCount;
-    }
-    public int getAccountCount() {
-        return accountCount;
-    }
-    public int getPosition() {
-        return position;
-    }
-    public String getBoldAccountName() {
-        return boldAccountName;
-    }
-    public int getAccountPosition() {
-        return accountPosition;
-    }
-    public TransactionRowHolder getHolder() {
-        return holder;
-    }
-    public TransactionListAdapter.LoaderStep getStep() {
-        return step;
-    }
-    public LedgerTransaction getTransaction() {
-        return transaction;
-    }
-    public LedgerTransactionAccount getAccount() {
-        return account;
-    }
-}
index 0d41e19a1f93fa51003e16b29fb4f606a3911719..876430f5de06e4c1b4b25a9061a5f9d74abfe94b 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2020 Damyan Ivanov.
+ * Copyright © 2021 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
 
 package net.ktnx.mobileledger.ui.transaction_list;
 
+import android.app.Activity;
+import android.content.Context;
+import android.graphics.Typeface;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.style.StyleSpan;
+import android.view.LayoutInflater;
 import android.view.View;
 import android.widget.LinearLayout;
 import android.widget.TextView;
 
+import androidx.annotation.ColorInt;
 import androidx.annotation.NonNull;
-import androidx.cardview.widget.CardView;
-import androidx.constraintlayout.widget.ConstraintLayout;
-import androidx.recyclerview.widget.RecyclerView;
+import androidx.annotation.Nullable;
 
 import net.ktnx.mobileledger.R;
-import net.ktnx.mobileledger.model.Data;
+import net.ktnx.mobileledger.databinding.TransactionListRowBinding;
+import net.ktnx.mobileledger.model.LedgerTransaction;
+import net.ktnx.mobileledger.model.LedgerTransactionAccount;
 import net.ktnx.mobileledger.model.TransactionListItem;
+import net.ktnx.mobileledger.utils.Colors;
+import net.ktnx.mobileledger.utils.Misc;
 
 import java.util.Observer;
 
-class TransactionRowHolder extends RecyclerView.ViewHolder {
-    final TextView tvDescription;
-    final TextView tvComment;
-    final LinearLayout tableAccounts;
-    final ConstraintLayout row;
-    final ConstraintLayout vDelimiter;
-    final CardView vTransaction;
-    final TextView tvDelimiterMonth, tvDelimiterDate;
-    final View vDelimiterThick;
-    final View vHeader;
-    final TextView tvLastUpdate;
+class TransactionRowHolder extends TransactionRowHolderBase {
+    private final TransactionListRowBinding b;
     TransactionListItem.Type lastType;
     private Observer lastUpdateObserver;
-    public TransactionRowHolder(@NonNull View itemView) {
-        super(itemView);
-        this.row = itemView.findViewById(R.id.transaction_row);
-        this.tvDescription = itemView.findViewById(R.id.transaction_row_description);
-        this.tvComment = itemView.findViewById(R.id.transaction_comment);
-        this.tableAccounts = itemView.findViewById(R.id.transaction_row_acc_amounts);
-        this.vDelimiter = itemView.findViewById(R.id.transaction_delimiter);
-        this.vTransaction = itemView.findViewById(R.id.transaction_card_view);
-        this.tvDelimiterDate = itemView.findViewById(R.id.transaction_delimiter_date);
-        this.tvDelimiterMonth = itemView.findViewById(R.id.transaction_delimiter_month);
-        this.vDelimiterThick = itemView.findViewById(R.id.transaction_delimiter_thick);
-        this.vHeader = itemView.findViewById(R.id.last_update_container);
-        this.tvLastUpdate = itemView.findViewById(R.id.last_update_text);
+    public TransactionRowHolder(@NonNull TransactionListRowBinding binding) {
+        super(binding.getRoot());
+        b = binding;
     }
-    private void initLastUpdateObserver() {
-        if (lastUpdateObserver != null)
-            return;
+    public void bind(@NonNull TransactionListItem item, @Nullable String boldAccountName) {
+        LedgerTransaction tr = item.getTransaction();
+        b.transactionRowDescription.setText(tr.getDescription());
+        String trComment = Misc.emptyIsNull(tr.getComment());
+        if (trComment == null)
+            b.transactionComment.setVisibility(View.GONE);
+        else {
+            b.transactionComment.setText(trComment);
+            b.transactionComment.setVisibility(View.VISIBLE);
+        }
 
-        lastUpdateObserver = (o, arg) -> setLastUpdateText(Data.lastTransactionsUpdateText.get());
+        if (Misc.emptyIsNull(item.getRunningTotal()) != null) {
+            b.transactionRunningTotal.setText(item.getRunningTotal());
+            b.transactionRunningTotal.setVisibility(View.VISIBLE);
+            b.transactionRunningTotalDivider.setVisibility(View.VISIBLE);
+        }
+        else {
+            b.transactionRunningTotal.setVisibility(View.GONE);
+            b.transactionRunningTotalDivider.setVisibility(View.GONE);
+        }
 
-        Data.lastTransactionsUpdateText.addObserver(lastUpdateObserver);
-    }
-    void setLastUpdateText(String text) {
-        tvLastUpdate.setText(text);
-    }
-    private void dropLastUpdateObserver() {
-        if (lastUpdateObserver == null)
-            return;
+        int rowIndex = 0;
+        Context ctx = b.getRoot()
+                       .getContext();
+        LayoutInflater inflater = ((Activity) ctx).getLayoutInflater();
+        for (LedgerTransactionAccount acc : tr.getAccounts()) {
+            LinearLayout row = (LinearLayout) b.transactionRowAccAmounts.getChildAt(rowIndex);
+            if (row == null) {
+                row = new LinearLayout(ctx);
+                inflater.inflate(R.layout.transaction_list_row_accounts_table_row, row);
+                b.transactionRowAccAmounts.addView(row);
+            }
 
-        Data.lastTransactionsUpdateText.deleteObserver(lastUpdateObserver);
-        lastUpdateObserver = null;
-    }
-    void setType(TransactionListItem.Type newType) {
-        if (newType == lastType)
-            return;
+            TextView dummyText = row.findViewById(R.id.dummy_text);
+            TextView accName = row.findViewById(R.id.transaction_list_acc_row_acc_name);
+            TextView accComment = row.findViewById(R.id.transaction_list_acc_row_acc_comment);
+            TextView accAmount = row.findViewById(R.id.transaction_list_acc_row_acc_amount);
+
+            if ((boldAccountName != null) && acc.getAccountName()
+                                                .startsWith(boldAccountName))
+            {
+                accName.setTextColor(Colors.primary);
+                accAmount.setTextColor(Colors.primary);
+
+                SpannableString ss = new SpannableString(Misc.addWrapHints(acc.getAccountName()));
+                ss.setSpan(new StyleSpan(Typeface.BOLD), 0, Misc.addWrapHints(boldAccountName)
+                                                                .length(),
+                        Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+                accName.setText(ss);
+            }
+            else {
+                @ColorInt int textColor = dummyText.getTextColors()
+                                                   .getDefaultColor();
+                accName.setTextColor(textColor);
+                accAmount.setTextColor(textColor);
+                accName.setText(Misc.addWrapHints(acc.getAccountName()));
+            }
 
-        switch (newType) {
-            case TRANSACTION:
-                vHeader.setVisibility(View.GONE);
-                vTransaction.setVisibility(View.VISIBLE);
-                vDelimiter.setVisibility(View.GONE);
-                dropLastUpdateObserver();
-                break;
-            case DELIMITER:
-                vHeader.setVisibility(View.GONE);
-                vTransaction.setVisibility(View.GONE);
-                vDelimiter.setVisibility(View.VISIBLE);
-                dropLastUpdateObserver();
-                break;
-            case HEADER:
-                vHeader.setVisibility(View.VISIBLE);
-                vTransaction.setVisibility(View.GONE);
-                vDelimiter.setVisibility(View.GONE);
-                initLastUpdateObserver();
-                break;
-            default:
-                throw new IllegalStateException("Unexpected value: " + newType);
+            String comment = acc.getComment();
+            if (comment != null && !comment.isEmpty()) {
+                accComment.setText(comment);
+                accComment.setVisibility(View.VISIBLE);
+            }
+            else {
+                accComment.setVisibility(View.GONE);
+            }
+            accAmount.setText(acc.toString());
+
+            rowIndex++;
         }
 
-        lastType = newType;
+        if (b.transactionRowAccAmounts.getChildCount() > rowIndex) {
+            b.transactionRowAccAmounts.removeViews(rowIndex,
+                    b.transactionRowAccAmounts.getChildCount() - rowIndex);
+        }
     }
 }
diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/transaction_list/TransactionRowHolderBase.java b/app/src/main/java/net/ktnx/mobileledger/ui/transaction_list/TransactionRowHolderBase.java
new file mode 100644 (file)
index 0000000..951cab6
--- /dev/null
@@ -0,0 +1,38 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui.transaction_list;
+
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+public class TransactionRowHolderBase extends RecyclerView.ViewHolder {
+    public TransactionRowHolderBase(@NonNull View itemView) {
+        super(itemView);
+    }
+    public TransactionListLastUpdateRowHolder asHeader() {
+        return (TransactionListLastUpdateRowHolder) this;
+    }
+    public TransactionRowHolder asTransaction() {
+        return (TransactionRowHolder) this;
+    }
+    public TransactionListDelimiterRowHolder asDelimiter() {
+        return (TransactionListDelimiterRowHolder) this;
+    }
+}
index 9d0c6aaaf51cf90713c96a4a108064c0495a9d82..348a5598a324073ea2415f353cd32a1019b72542 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2020 Damyan Ivanov.
+ * Copyright © 2024 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
@@ -17,6 +17,8 @@
 
 package net.ktnx.mobileledger.utils;
 
+import static net.ktnx.mobileledger.utils.Logger.debug;
+
 import android.app.Activity;
 import android.content.res.ColorStateList;
 import android.content.res.Resources;
@@ -28,17 +30,16 @@ import androidx.lifecycle.MutableLiveData;
 
 import net.ktnx.mobileledger.BuildConfig;
 import net.ktnx.mobileledger.R;
-import net.ktnx.mobileledger.model.MobileLedgerProfile;
+import net.ktnx.mobileledger.db.Profile;
 import net.ktnx.mobileledger.ui.HueRing;
 
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Locale;
 import java.util.Objects;
 
-import static net.ktnx.mobileledger.utils.Logger.debug;
-
 public class Colors {
     public static final int DEFAULT_HUE_DEG = 261;
     public static final MutableLiveData<Integer> themeWatch = new MutableLiveData<>(0);
@@ -67,23 +68,23 @@ public class Colors {
              };
     private static final HashMap<Integer, Integer> themePrimaryColor = new HashMap<>();
     public static @ColorInt
-    int secondary;
+    int primary;
     @ColorInt
     public static int tableRowDarkBG;
-    public static int profileThemeId = -1;
+    public static int profileThemeId = DEFAULT_HUE_DEG;
     public static void refreshColors(Resources.Theme theme) {
         TypedValue tv = new TypedValue();
         theme.resolveAttribute(R.attr.table_row_dark_bg, tv, true);
         tableRowDarkBG = tv.data;
-        theme.resolveAttribute(R.attr.colorSecondary, tv, true);
-        secondary = tv.data;
+        theme.resolveAttribute(androidx.appcompat.R.attr.colorPrimary, tv, true);
+        primary = tv.data;
 
         if (themePrimaryColor.size() == 0) {
             for (int themeId : themeIDs) {
                 Resources.Theme tmpTheme = theme.getResources()
                                                 .newTheme();
                 tmpTheme.applyStyle(themeId, true);
-                tmpTheme.resolveAttribute(R.attr.colorPrimary, tv, false);
+                tmpTheme.resolveAttribute(androidx.appcompat.R.attr.colorPrimary, tv, false);
                 themePrimaryColor.put(themeId, tv.data);
             }
         }
@@ -151,7 +152,7 @@ public class Colors {
         }
         return colors;
     }
-    public static int getNewProfileThemeHue(ArrayList<MobileLedgerProfile> profiles) {
+    public static int getNewProfileThemeHue(List<Profile> profiles) {
         if ((profiles == null) || (profiles.size() == 0))
             return DEFAULT_HUE_DEG;
 
@@ -159,14 +160,14 @@ public class Colors {
 
         if (profiles.size() == 1) {
             int opposite = profiles.get(0)
-                                   .getThemeHue() + 180;
+                                   .getTheme() + 180;
             opposite %= 360;
             chosenHue = opposite;
         }
         else {
             ArrayList<Integer> hues = new ArrayList<>();
-            for (MobileLedgerProfile p : profiles) {
-                int hue = p.getThemeHue();
+            for (Profile p : profiles) {
+                int hue = p.getTheme();
                 if (hue == -1)
                     hue = DEFAULT_HUE_DEG;
                 hues.add(hue);
@@ -179,7 +180,7 @@ public class Colors {
                         huesSB.append(", ");
                     huesSB.append(h);
                 }
-                debug("profiles", String.format("used hues: %s", huesSB.toString()));
+                debug("profiles", String.format("used hues: %s", huesSB));
             }
             hues.add(hues.get(0));
 
diff --git a/app/src/main/java/net/ktnx/mobileledger/utils/MLDB.java b/app/src/main/java/net/ktnx/mobileledger/utils/MLDB.java
deleted file mode 100644 (file)
index 178b7b2..0000000
+++ /dev/null
@@ -1,244 +0,0 @@
-/*
- * Copyright © 2019 Damyan Ivanov.
- * This file is part of MoLe.
- * MoLe is free software: you can distribute it and/or modify it
- * under the term of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your opinion), any later version.
- *
- * MoLe is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License terms for details.
- *
- * You should have received a copy of the GNU General Public License
- * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package net.ktnx.mobileledger.utils;
-
-import android.annotation.TargetApi;
-import android.content.Context;
-import android.database.Cursor;
-import android.database.sqlite.SQLiteDatabase;
-import android.os.AsyncTask;
-import android.os.Build;
-import android.widget.AutoCompleteTextView;
-import android.widget.FilterQueryProvider;
-import android.widget.SimpleCursorAdapter;
-
-import androidx.annotation.NonNull;
-
-import net.ktnx.mobileledger.App;
-import net.ktnx.mobileledger.async.DbOpQueue;
-import net.ktnx.mobileledger.async.DescriptionSelectedCallback;
-import net.ktnx.mobileledger.model.Data;
-import net.ktnx.mobileledger.model.MobileLedgerProfile;
-
-import org.jetbrains.annotations.NonNls;
-
-import static net.ktnx.mobileledger.utils.Logger.debug;
-
-public final class MLDB {
-    public static final String ACCOUNTS_TABLE = "accounts";
-    public static final String DESCRIPTION_HISTORY_TABLE = "description_history";
-    public static final String OPT_LAST_SCRAPE = "last_scrape";
-    @NonNls
-    public static final String OPT_PROFILE_UUID = "profile_uuid";
-    private static final String NO_PROFILE = "-";
-    @SuppressWarnings("unused")
-    static public int getIntOption(String name, int default_value) {
-        String s = getOption(name, String.valueOf(default_value));
-        try {
-            return Integer.parseInt(s);
-        }
-        catch (Exception e) {
-            debug("db", "returning default int value of " + name, e);
-            return default_value;
-        }
-    }
-    @SuppressWarnings("unused")
-    static public long getLongOption(String name, long default_value) {
-        String s = getOption(name, String.valueOf(default_value));
-        try {
-            return Long.parseLong(s);
-        }
-        catch (Exception e) {
-            debug("db", "returning default long value of " + name, e);
-            return default_value;
-        }
-    }
-    static public void getOption(String name, String defaultValue, GetOptCallback cb) {
-        AsyncTask<Void, Void, String> t = new AsyncTask<Void, Void, String>() {
-            @Override
-            protected String doInBackground(Void... params) {
-                SQLiteDatabase db = App.getDatabase();
-                try (Cursor cursor = db.rawQuery(
-                        "select value from options where profile=? and name=?",
-                        new String[]{NO_PROFILE, name}))
-                {
-                    if (cursor.moveToFirst()) {
-                        String result = cursor.getString(0);
-
-                        if (result == null)
-                            result = defaultValue;
-
-                        debug("async-db", "option " + name + "=" + result);
-                        return result;
-                    }
-                    else
-                        return defaultValue;
-                }
-                catch (Exception e) {
-                    debug("db", "returning default value for " + name, e);
-                    return defaultValue;
-                }
-            }
-            @Override
-            protected void onPostExecute(String result) {
-                cb.onResult(result);
-            }
-        };
-
-        t.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, (Void) null);
-    }
-    static public String getOption(String name, String default_value) {
-        debug("db", "about to fetch option " + name);
-        SQLiteDatabase db = App.getDatabase();
-        try (Cursor cursor = db.rawQuery("select value from options where profile=? and name=?",
-                new String[]{NO_PROFILE, name}))
-        {
-            if (cursor.moveToFirst()) {
-                String result = cursor.getString(0);
-
-                if (result == null)
-                    result = default_value;
-
-                debug("db", "option " + name + "=" + result);
-                return result;
-            }
-            else
-                return default_value;
-        }
-        catch (Exception e) {
-            debug("db", "returning default value for " + name, e);
-            return default_value;
-        }
-    }
-    static public void setOption(String name, String value) {
-        debug("option", String.format("%s := %s", name, value));
-        DbOpQueue.add("insert or replace into options(profile, name, value) values(?, ?, ?);",
-                new String[]{NO_PROFILE, name, value});
-    }
-    @SuppressWarnings("unused")
-    static public void setLongOption(String name, long value) {
-        setOption(name, String.valueOf(value));
-    }
-    @TargetApi(Build.VERSION_CODES.N)
-    public static void hookAutocompletionAdapter(final Context context,
-                                                 final AutoCompleteTextView view,
-                                                 final String table, final String field) {
-        hookAutocompletionAdapter(context, view, table, field, true, null, null);
-    }
-    @TargetApi(Build.VERSION_CODES.N)
-    public static void hookAutocompletionAdapter(final Context context,
-                                                 final AutoCompleteTextView view,
-                                                 final String table, final String field,
-                                                 final boolean profileSpecific,
-                                                 final DescriptionSelectedCallback callback,
-                                                 final MobileLedgerProfile profile) {
-        String[] from = {field};
-        int[] to = {android.R.id.text1};
-        SimpleCursorAdapter adapter =
-                new SimpleCursorAdapter(context, android.R.layout.simple_dropdown_item_1line, null,
-                        from, to, 0);
-        adapter.setStringConversionColumn(1);
-
-        FilterQueryProvider provider = constraint -> {
-            if (constraint == null)
-                return null;
-
-            String str = constraint.toString()
-                                   .toUpperCase();
-            debug("autocompletion", "Looking for " + str);
-
-            String sql;
-            String[] params;
-            if (profileSpecific) {
-                MobileLedgerProfile p = (profile == null) ? Data.getProfile() : profile;
-                sql = String.format(
-                        "SELECT rowid as _id, %s, CASE WHEN %s_upper LIKE ?||'%%' THEN 1 " +
-                        "WHEN %s_upper LIKE '%%:'||?||'%%' then 2 " +
-                        "WHEN %s_upper LIKE '%% '||?||'%%' THEN 3 " + "ELSE 9 END " + "FROM %s " +
-                        "WHERE profile=? AND %s_upper LIKE '%%'||?||'%%' " +
-                        "ORDER BY 3, %s_upper, 1;", field, field, field, field, table, field,
-                        field);
-                params = new String[]{str, str, str, p.getUuid(), str};
-            }
-            else {
-                sql = String.format(
-                        "SELECT rowid as _id, %s, CASE WHEN %s_upper LIKE ?||'%%' THEN 1 " +
-                        "WHEN %s_upper LIKE '%%:'||?||'%%' THEN 2 " +
-                        "WHEN %s_upper LIKE '%% '||?||'%%' THEN 3 " + "ELSE 9 END " + "FROM %s " +
-                        "WHERE %s_upper LIKE '%%'||?||'%%' " + "ORDER BY 3, %s_upper, 1;", field,
-                        field, field, field, table, field, field);
-                params = new String[]{str, str, str, str};
-            }
-            debug("autocompletion", sql);
-            SQLiteDatabase db = App.getDatabase();
-
-            return db.rawQuery(sql, params);
-        };
-
-        adapter.setFilterQueryProvider(provider);
-
-        view.setAdapter(adapter);
-
-        if (callback != null)
-            view.setOnItemClickListener(
-                    (parent, itemView, position, id) -> callback.descriptionSelected(
-                            String.valueOf(view.getText())));
-    }
-    public static void queryInBackground(@NonNull String statement, @NonNull String[] params,
-                                         @NonNull CallbackHelper callbackHelper) {
-        /* All callbacks are called in the new (asynchronous) thread! */
-        Thread t = new Thread(() -> {
-            callbackHelper.onStart();
-            try {
-                SQLiteDatabase db = App.getDatabase();
-
-                try (Cursor cursor = db.rawQuery(statement, params)) {
-                    boolean gotRow = false;
-                    while (cursor.moveToNext()) {
-                        gotRow = true;
-                        if (!callbackHelper.onRow(cursor))
-                            break;
-                    }
-                    if (!gotRow) {
-                        callbackHelper.onNoRows();
-                    }
-                }
-            }
-            catch (Exception e) {
-                callbackHelper.onException(e);
-            }
-            finally {
-                callbackHelper.onDone();
-            }
-        });
-
-        t.start();
-    }
-    /* MLDB.CallbackHelper -- Abstract class for asynchronous SQL query callbacks */
-    @SuppressWarnings("WeakerAccess")
-    abstract public static class CallbackHelper {
-        public void onStart() {}
-        public abstract boolean onRow(@NonNull Cursor cursor);
-        public void onNoRows() {}
-        public void onException(Exception exception) {
-            Logger.debug("MLDB", "Exception in asynchronous SQL", exception);
-        }
-        public void onDone() {}
-    }
-}
-
index a79bcf93abee7c4845ea2c288a43d3e983ca9b19..67dc0958686a9722f15b3f797d3634740c531dbc 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2020 Damyan Ivanov.
+ * Copyright © 2021 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
@@ -19,16 +19,23 @@ package net.ktnx.mobileledger.utils;
 
 import android.app.Activity;
 import android.content.res.Configuration;
+import android.os.Handler;
+import android.os.Looper;
+import android.text.Editable;
 import android.view.WindowManager;
 
 import androidx.annotation.Nullable;
 import androidx.fragment.app.Fragment;
 import androidx.fragment.app.FragmentActivity;
 
+import org.jetbrains.annotations.Contract;
+
 public class Misc {
+    public static final char ZERO_WIDTH_SPACE = '\u200B';
     public static boolean isZero(float f) {
         return (f < 0.005) && (f > -0.005);
     }
+    public static boolean equalFloats(float a, float b) { return isZero(a - b); }
     public static void showSoftKeyboard(Activity activity) {
         // make the keyboard appear
         Configuration cf = activity.getResources()
@@ -58,11 +65,16 @@ public class Misc {
 
     }
     public static String emptyIsNull(String str) {
-        return "".equals(str) ? null : str;
+        return str != null && str.isEmpty() ? null : str;
     }
     public static String nullIsEmpty(String str) {
         return (str == null) ? "" : str;
     }
+    public static String nullIsEmpty(Editable e) {
+        if (e == null)
+            return "";
+        return e.toString();
+    }
     public static boolean equalStrings(String u, CharSequence text) {
         return nullIsEmpty(u).equals(text.toString());
     }
@@ -75,4 +87,43 @@ public class Misc {
 
         return string.trim();
     }
+    @Contract(value = "null, null -> true; null, !null -> false; !null, null -> false", pure = true)
+    public static boolean equalIntegers(Integer a, Integer b) {
+        if (a == null && b == null)
+            return true;
+        if (a == null || b == null)
+            return false;
+
+        return a.equals(b);
+    }
+    @Contract(value = "null, null -> true; null, !null -> false; !null, null -> false", pure = true)
+    public static boolean equalLongs(Long a, Long b) {
+        if (a == null && b == null)
+            return true;
+        if (a == null || b == null)
+            return false;
+
+        return a.equals(b);
+    }
+    public static void onMainThread(Runnable r) {
+        new Handler(Looper.getMainLooper()).post(r);
+    }
+    public static String addWrapHints(String input) {
+        if (input == null)
+            return null;
+        StringBuilder result = new StringBuilder();
+        int lastPos = 0;
+        int pos = input.indexOf(':');
+
+        while (pos >= 0) {
+            result.append(input.substring(lastPos, pos + 1))
+                  .append(ZERO_WIDTH_SPACE);
+            lastPos = pos + 1;
+            pos = input.indexOf(':', lastPos + 1);
+        }
+        if (lastPos > 0)
+            result.append(input.substring(lastPos));
+
+        return result.toString();
+    }
 }
diff --git a/app/src/main/java/net/ktnx/mobileledger/utils/MobileLedgerDatabase.java b/app/src/main/java/net/ktnx/mobileledger/utils/MobileLedgerDatabase.java
deleted file mode 100644 (file)
index 8f8be51..0000000
+++ /dev/null
@@ -1,121 +0,0 @@
-/*
- * Copyright © 2020 Damyan Ivanov.
- * This file is part of MoLe.
- * MoLe is free software: you can distribute it and/or modify it
- * under the term of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your opinion), any later version.
- *
- * MoLe is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License terms for details.
- *
- * You should have received a copy of the GNU General Public License
- * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package net.ktnx.mobileledger.utils;
-
-import android.app.Application;
-import android.content.res.Resources;
-import android.database.SQLException;
-import android.database.sqlite.SQLiteDatabase;
-import android.database.sqlite.SQLiteOpenHelper;
-import android.util.Log;
-
-import androidx.lifecycle.MutableLiveData;
-
-import net.ktnx.mobileledger.BuildConfig;
-
-import java.io.BufferedReader;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.util.Locale;
-
-import static net.ktnx.mobileledger.utils.Logger.debug;
-
-public class MobileLedgerDatabase extends SQLiteOpenHelper {
-    public static final MutableLiveData<Boolean> initComplete = new MutableLiveData<>(false);
-    private static final String DB_NAME = "MoLe.db";
-    private static final int LATEST_REVISION = 41;
-    private static final String CREATE_DB_SQL = "create_db";
-    private final Application mContext;
-
-    public MobileLedgerDatabase(Application context) {
-        super(context, DB_NAME, null, LATEST_REVISION);
-        debug("db", "creating helper instance");
-        mContext = context;
-        super.setWriteAheadLoggingEnabled(true);
-    }
-
-    @Override
-    public void onCreate(SQLiteDatabase db) {
-        debug("db", "onCreate called");
-        applyRevisionFile(db, CREATE_DB_SQL);
-    }
-
-    @Override
-    public void onConfigure(SQLiteDatabase db) {
-        super.onConfigure(db);
-        db.execSQL("pragma case_sensitive_like=ON;");
-        if (BuildConfig.DEBUG)
-            db.execSQL("PRAGMA foreign_keys=ON");
-    }
-    @Override
-    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
-        debug("db",
-                String.format(Locale.US, "needs upgrade from version %d to version %d", oldVersion,
-                        newVersion));
-        for (int i = oldVersion + 1; i <= newVersion; i++)
-            applyRevision(db, i);
-    }
-    private void applyRevision(SQLiteDatabase db, int rev_no) {
-        String rev_file = String.format(Locale.US, "sql_%d", rev_no);
-
-        applyRevisionFile(db, rev_file);
-    }
-    private void applyRevisionFile(SQLiteDatabase db, String rev_file) {
-        final Resources rm = mContext.getResources();
-        int res_id = rm.getIdentifier(rev_file, "raw", mContext.getPackageName());
-        if (res_id == 0)
-            throw new SQLException(String.format(Locale.US, "No resource for %s", rev_file));
-        db.beginTransaction();
-        try (InputStream res = rm.openRawResource(res_id)) {
-            debug("db", "Applying " + rev_file);
-            InputStreamReader isr = new InputStreamReader(res);
-            BufferedReader reader = new BufferedReader(isr);
-
-            String line;
-            int line_no = 1;
-            while ((line = reader.readLine()) != null) {
-                if (line.startsWith("--")) {
-                    line_no++;
-                    continue;
-                }
-                if (line.isEmpty()) {
-                    line_no++;
-                    continue;
-                }
-                try {
-                    db.execSQL(line);
-                }
-                catch (Exception e) {
-                    throw new RuntimeException(
-                            String.format("Error applying %s, line %d", rev_file, line_no), e);
-                }
-                line_no++;
-            }
-
-            db.setTransactionSuccessful();
-        }
-        catch (IOException e) {
-            Log.e("db", String.format("Error opening raw resource for %s", rev_file));
-            e.printStackTrace();
-        }
-        finally {
-            db.endTransaction();
-        }
-    }
-}
index c9f0151ba4b84b12175bbf828eb425e7607c07f8..97c09911f60a6aa5547eef8d12512c01f70b777a 100644 (file)
@@ -19,7 +19,7 @@ package net.ktnx.mobileledger.utils;
 
 import androidx.annotation.NonNull;
 
-import net.ktnx.mobileledger.model.MobileLedgerProfile;
+import net.ktnx.mobileledger.db.Profile;
 
 import org.jetbrains.annotations.NotNull;
 
@@ -32,9 +32,9 @@ import static net.ktnx.mobileledger.utils.Logger.debug;
 public final class NetworkUtil {
     private static final int thirtySeconds = 30000;
     @NotNull
-    public static HttpURLConnection prepareConnection(@NonNull MobileLedgerProfile profile,
+    public static HttpURLConnection prepareConnection(@NonNull Profile profile,
                                                       @NonNull String path) throws IOException {
-        return prepareConnection(profile.getUrl(), path, profile.isAuthEnabled());
+        return prepareConnection(profile.getUrl(), path, profile.useAuthentication());
     }
     public static HttpURLConnection prepareConnection(@NonNull String url, @NonNull String path,
                                                       boolean authEnabled) throws IOException {
diff --git a/app/src/main/java/net/ktnx/mobileledger/utils/Profiler.java b/app/src/main/java/net/ktnx/mobileledger/utils/Profiler.java
new file mode 100644 (file)
index 0000000..1545090
--- /dev/null
@@ -0,0 +1,58 @@
+/*
+ * Copyright © 2021 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.utils;
+
+import net.ktnx.mobileledger.BuildConfig;
+
+import java.util.Locale;
+
+public class Profiler {
+    private final String name;
+    private long opStart = 0;
+    private long opCount = 0;
+    private long opMills = 0;
+    public Profiler(String name) {
+        this.name = name;
+    }
+    public void opStart() {
+        if (!BuildConfig.DEBUG)
+            return;
+
+        if (opStart != 0)
+            throw new IllegalStateException("opStart() already called with no opEnd()");
+        this.opStart = System.currentTimeMillis();
+        opCount++;
+    }
+    public void opEnd() {
+        if (!BuildConfig.DEBUG)
+            return;
+
+        if (opStart == 0)
+            throw new IllegalStateException("opStart() not called");
+        opMills += System.currentTimeMillis() - opStart;
+        opStart = 0;
+    }
+    public void dumpStats() {
+        if (!BuildConfig.DEBUG)
+            return;
+
+        Logger.debug("profiler", String.format(Locale.ROOT,
+                "Operation '%s' executed %d times for %d ms. Average time %4.2fms", name, opCount,
+                opMills, 1.0 * opMills / opCount));
+    }
+}
index 51cf2532d185ce6b1bc03bcf7a5ffe2c56e41500..52db9bdddb3d47675d989c219f8ecd321ef01778 100644 (file)
@@ -22,6 +22,7 @@ import androidx.annotation.Nullable;
 
 import java.util.Calendar;
 import java.util.Date;
+import java.util.Locale;
 
 public class SimpleDate implements Comparable<SimpleDate> {
     public final int year;
@@ -96,4 +97,7 @@ public class SimpleDate implements Comparable<SimpleDate> {
         calendar.set(year, month, day);
         return calendar;
     }
+    public String toString() {
+        return String.format(Locale.US, "%d-%02d-%02d", year, month, day);
+    }
 }
diff --git a/app/src/main/res/anim/layout_slide_down.xml b/app/src/main/res/anim/layout_slide_down.xml
deleted file mode 100644 (file)
index a91dafc..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
-  ~ Copyright © 2019 Damyan Ivanov.
-  ~ This file is part of MoLe.
-  ~ MoLe is free software: you can distribute it and/or modify it
-  ~ under the term of the GNU General Public License as published by
-  ~ the Free Software Foundation, either version 3 of the License, or
-  ~ (at your opinion), any later version.
-  ~
-  ~ MoLe is distributed in the hope that it will be useful,
-  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
-  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-  ~ GNU General Public License terms for details.
-  ~
-  ~ You should have received a copy of the GNU General Public License
-  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
-  -->
-
-<layoutAnimation xmlns:android="http://schemas.android.com/apk/res/android"
-    android:animation="@anim/slide_in_right"
-    android:interpolator="@android:anim/accelerate_decelerate_interpolator" />
\ No newline at end of file
diff --git a/app/src/main/res/anim/rotate_180.xml b/app/src/main/res/anim/rotate_180.xml
deleted file mode 100644 (file)
index abe8113..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
-  ~ Copyright © 2019 Damyan Ivanov.
-  ~ This file is part of MoLe.
-  ~ MoLe is free software: you can distribute it and/or modify it
-  ~ under the term of the GNU General Public License as published by
-  ~ the Free Software Foundation, either version 3 of the License, or
-  ~ (at your opinion), any later version.
-  ~
-  ~ MoLe is distributed in the hope that it will be useful,
-  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
-  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-  ~ GNU General Public License terms for details.
-  ~
-  ~ You should have received a copy of the GNU General Public License
-  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
-  -->
-
-<set xmlns:android="http://schemas.android.com/apk/res/android"
-    android:duration="@android:integer/config_shortAnimTime"
-    android:fillAfter="true">
-    <rotate
-        android:fromDegrees="0"
-        android:pivotX="50%"
-        android:pivotY="50%"
-        android:toDegrees="180" />
-</set>
\ No newline at end of file
diff --git a/app/src/main/res/anim/rotate_180_back.xml b/app/src/main/res/anim/rotate_180_back.xml
deleted file mode 100644 (file)
index 87761a0..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!--
-  ~ Copyright © 2019 Damyan Ivanov.
-  ~ This file is part of MoLe.
-  ~ MoLe is free software: you can distribute it and/or modify it
-  ~ under the term of the GNU General Public License as published by
-  ~ the Free Software Foundation, either version 3 of the License, or
-  ~ (at your opinion), any later version.
-  ~
-  ~ MoLe is distributed in the hope that it will be useful,
-  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
-  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-  ~ GNU General Public License terms for details.
-  ~
-  ~ You should have received a copy of the GNU General Public License
-  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
-  -->
-
-<set xmlns:android="http://schemas.android.com/apk/res/android"
-    android:duration="@android:integer/config_shortAnimTime"
-    android:fillAfter="true">
-    <rotate
-        android:fromDegrees="180"
-        android:pivotX="50%"
-        android:pivotY="50%"
-        android:toDegrees="0" />
-</set>
\ No newline at end of file
diff --git a/app/src/main/res/anim/slide_down.xml b/app/src/main/res/anim/slide_down.xml
deleted file mode 100644 (file)
index 5c3f8ae..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
-  ~ Copyright © 2019 Damyan Ivanov.
-  ~ This file is part of MoLe.
-  ~ MoLe is free software: you can distribute it and/or modify it
-  ~ under the term of the GNU General Public License as published by
-  ~ the Free Software Foundation, either version 3 of the License, or
-  ~ (at your opinion), any later version.
-  ~
-  ~ MoLe is distributed in the hope that it will be useful,
-  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
-  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-  ~ GNU General Public License terms for details.
-  ~
-  ~ You should have received a copy of the GNU General Public License
-  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
-  -->
-
-<set xmlns:android="http://schemas.android.com/apk/res/android"
-    android:duration="@android:integer/config_shortAnimTime"
-    android:fillAfter="true"
-    android:fillEnabled="true">
-    <scale
-        android:fillAfter="false"
-        android:fromYScale="0.0"
-        android:interpolator="@android:anim/accelerate_decelerate_interpolator"
-        android:toYScale="1.0"
-        android:fromXScale="1.0"
-        android:toXScale="1.0"/>
-</set>
\ No newline at end of file
diff --git a/app/src/main/res/anim/slide_in_right.xml b/app/src/main/res/anim/slide_in_right.xml
deleted file mode 100644 (file)
index 9ffc9e6..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!--
-  ~ Copyright © 2019 Damyan Ivanov.
-  ~ This file is part of MoLe.
-  ~ MoLe is free software: you can distribute it and/or modify it
-  ~ under the term of the GNU General Public License as published by
-  ~ the Free Software Foundation, either version 3 of the License, or
-  ~ (at your opinion), any later version.
-  ~
-  ~ MoLe is distributed in the hope that it will be useful,
-  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
-  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-  ~ GNU General Public License terms for details.
-  ~
-  ~ You should have received a copy of the GNU General Public License
-  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
-  -->
-
-<set xmlns:android="http://schemas.android.com/apk/res/android"
-    android:duration="@android:integer/config_shortAnimTime">
-    <translate
-        android:fromXDelta="100%"
-        android:toXDelta="0%" />
-</set>
\ No newline at end of file
diff --git a/app/src/main/res/anim/slide_out_right.xml b/app/src/main/res/anim/slide_out_right.xml
deleted file mode 100644 (file)
index 1cb9ff2..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!--
-  ~ Copyright © 2019 Damyan Ivanov.
-  ~ This file is part of MoLe.
-  ~ MoLe is free software: you can distribute it and/or modify it
-  ~ under the term of the GNU General Public License as published by
-  ~ the Free Software Foundation, either version 3 of the License, or
-  ~ (at your opinion), any later version.
-  ~
-  ~ MoLe is distributed in the hope that it will be useful,
-  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
-  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-  ~ GNU General Public License terms for details.
-  ~
-  ~ You should have received a copy of the GNU General Public License
-  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
-  -->
-
-<set xmlns:android="http://schemas.android.com/apk/res/android"
-    android:duration="@android:integer/config_shortAnimTime">
-    <translate
-        android:fromXDelta="0%"
-        android:toXDelta="100%" />
-    <alpha
-        android:fromAlpha="1.0"
-        android:toAlpha="0.0" />
-</set>
\ No newline at end of file
diff --git a/app/src/main/res/anim/slide_up.xml b/app/src/main/res/anim/slide_up.xml
deleted file mode 100644 (file)
index 0e2ce6d..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
-  ~ Copyright © 2019 Damyan Ivanov.
-  ~ This file is part of MoLe.
-  ~ MoLe is free software: you can distribute it and/or modify it
-  ~ under the term of the GNU General Public License as published by
-  ~ the Free Software Foundation, either version 3 of the License, or
-  ~ (at your opinion), any later version.
-  ~
-  ~ MoLe is distributed in the hope that it will be useful,
-  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
-  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-  ~ GNU General Public License terms for details.
-  ~
-  ~ You should have received a copy of the GNU General Public License
-  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
-  -->
-
-<set xmlns:android="http://schemas.android.com/apk/res/android"
-    android:duration="@android:integer/config_shortAnimTime"
-    android:fillAfter="true"
-    android:fillEnabled="true">
-    <scale
-        android:fillAfter="false"
-        android:fromYScale="1.0"
-        android:interpolator="@android:anim/accelerate_decelerate_interpolator"
-        android:toYScale="0.0"
-        android:fromXScale="1.0"
-        android:toXScale="1.0"/>
-</set>
\ No newline at end of file
index f74bcb9c49092aebb0d01be458218b9415ea5a1e..b82ac92d9abc5e9fedea840ca7a250a9194da7fc 100644 (file)
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
 <!--
-  ~ Copyright © 2020 Damyan Ivanov.
+  ~ Copyright © 2021 Damyan Ivanov.
   ~ This file is part of MoLe.
   ~ MoLe is free software: you can distribute it and/or modify it
   ~ under the term of the GNU General Public License as published by
@@ -18,5 +18,5 @@
 
 <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
     <background android:drawable="@color/ic_launcher_background"/>
-    <foreground android:drawable="@drawable/ic_launcher_foreground"/>
+    <foreground android:drawable="@drawable/launcher_foreground" />
 </adaptive-icon>
\ No newline at end of file
index f74bcb9c49092aebb0d01be458218b9415ea5a1e..b82ac92d9abc5e9fedea840ca7a250a9194da7fc 100644 (file)
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
 <!--
-  ~ Copyright © 2020 Damyan Ivanov.
+  ~ Copyright © 2021 Damyan Ivanov.
   ~ This file is part of MoLe.
   ~ MoLe is free software: you can distribute it and/or modify it
   ~ under the term of the GNU General Public License as published by
@@ -18,5 +18,5 @@
 
 <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
     <background android:drawable="@color/ic_launcher_background"/>
-    <foreground android:drawable="@drawable/ic_launcher_foreground"/>
+    <foreground android:drawable="@drawable/launcher_foreground" />
 </adaptive-icon>
\ No newline at end of file
diff --git a/app/src/main/res/drawable-anydpi/app_icon_bg.xml b/app/src/main/res/drawable-anydpi/app_icon_bg.xml
deleted file mode 100644 (file)
index daca5c5..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
-  ~ Copyright © 2020 Damyan Ivanov.
-  ~ This file is part of MoLe.
-  ~ MoLe is free software: you can distribute it and/or modify it
-  ~ under the term of the GNU General Public License as published by
-  ~ the Free Software Foundation, either version 3 of the License, or
-  ~ (at your opinion), any later version.
-  ~
-  ~ MoLe is distributed in the hope that it will be useful,
-  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
-  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-  ~ GNU General Public License terms for details.
-  ~
-  ~ You should have received a copy of the GNU General Public License
-  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
-  -->
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
-    android:width="108dp"
-    android:height="108dp"
-    android:autoMirrored="true"
-    android:viewportWidth="90"
-    android:viewportHeight="90"
-    >
-    <path
-        android:fillAlpha="1"
-        android:fillColor="#935FF2"
-        android:fillType="nonZero"
-        android:pathData="M0,0 l0,90 l90,0 l0,-90 z"
-        android:strokeWidth="0"
-        android:strokeAlpha="1"
-        android:strokeColor="#00000000"
-        android:strokeLineCap="square"
-        android:strokeLineJoin="round"
-        />
-</vector>
\ No newline at end of file
diff --git a/app/src/main/res/drawable-anydpi/checkbox_star_black.xml b/app/src/main/res/drawable-anydpi/checkbox_star_black.xml
deleted file mode 100644 (file)
index 2f49039..0000000
+++ /dev/null
@@ -1,25 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!--
-  ~ Copyright © 2020 Damyan Ivanov.
-  ~ This file is part of MoLe.
-  ~ MoLe is free software: you can distribute it and/or modify it
-  ~ under the term of the GNU General Public License as published by
-  ~ the Free Software Foundation, either version 3 of the License, or
-  ~ (at your opinion), any later version.
-  ~
-  ~ MoLe is distributed in the hope that it will be useful,
-  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
-  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-  ~ GNU General Public License terms for details.
-  ~
-  ~ You should have received a copy of the GNU General Public License
-  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
-  -->
-
-<selector xmlns:android="http://schemas.android.com/apk/res/android">
-    <item android:drawable="@drawable/ic_star_border_black_24dp"
-        android:state_checked="false"/>
-    <item android:drawable="@drawable/ic_star_black_24dp"
-        android:state_checked="true"/>
-    <item android:drawable="@drawable/ic_star_border_black_24dp" />
-</selector>
\ No newline at end of file
diff --git a/app/src/main/res/drawable-anydpi/checkbox_star_white.xml b/app/src/main/res/drawable-anydpi/checkbox_star_white.xml
deleted file mode 100644 (file)
index d1f2df2..0000000
+++ /dev/null
@@ -1,25 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!--
-  ~ Copyright © 2020 Damyan Ivanov.
-  ~ This file is part of MoLe.
-  ~ MoLe is free software: you can distribute it and/or modify it
-  ~ under the term of the GNU General Public License as published by
-  ~ the Free Software Foundation, either version 3 of the License, or
-  ~ (at your opinion), any later version.
-  ~
-  ~ MoLe is distributed in the hope that it will be useful,
-  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
-  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-  ~ GNU General Public License terms for details.
-  ~
-  ~ You should have received a copy of the GNU General Public License
-  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
-  -->
-
-<selector xmlns:android="http://schemas.android.com/apk/res/android">
-    <item android:drawable="@drawable/ic_star_border_white_24dp"
-        android:state_checked="false"/>
-    <item android:drawable="@drawable/ic_star_white_24dp"
-        android:state_checked="true"/>
-    <item android:drawable="@drawable/ic_star_border_white_24dp" />
-</selector>
\ No newline at end of file
diff --git a/app/src/main/res/drawable-anydpi/dashed_border_1dp.xml b/app/src/main/res/drawable-anydpi/dashed_border_1dp.xml
deleted file mode 100644 (file)
index 1360399..0000000
+++ /dev/null
@@ -1,30 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
-  ~ Copyright © 2020 Damyan Ivanov.
-  ~ This file is part of MoLe.
-  ~ MoLe is free software: you can distribute it and/or modify it
-  ~ under the term of the GNU General Public License as published by
-  ~ the Free Software Foundation, either version 3 of the License, or
-  ~ (at your opinion), any later version.
-  ~
-  ~ MoLe is distributed in the hope that it will be useful,
-  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
-  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-  ~ GNU General Public License terms for details.
-  ~
-  ~ You should have received a copy of the GNU General Public License
-  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
-  -->
-
-<shape xmlns:android="http://schemas.android.com/apk/res/android"
-    android:shape="line"
-    android:thickness="1dp"
-    android:tint="?colorSecondary"
-    >
-    <stroke
-        android:width="1dp"
-        android:color="?colorSecondary"
-        android:dashWidth="2dp"
-        android:dashGap="6dp"
-        />
-
-</shape>
\ No newline at end of file
diff --git a/app/src/main/res/drawable-anydpi/expand_more_black_24dp.xml b/app/src/main/res/drawable-anydpi/expand_more_black_24dp.xml
deleted file mode 100644 (file)
index 8213381..0000000
+++ /dev/null
@@ -1,28 +0,0 @@
-<!--
-  ~ Copyright © 2020 Damyan Ivanov.
-  ~ This file is part of MoLe.
-  ~ MoLe is free software: you can distribute it and/or modify it
-  ~ under the term of the GNU General Public License as published by
-  ~ the Free Software Foundation, either version 3 of the License, or
-  ~ (at your opinion), any later version.
-  ~
-  ~ MoLe is distributed in the hope that it will be useful,
-  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
-  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-  ~ GNU General Public License terms for details.
-  ~
-  ~ You should have received a copy of the GNU General Public License
-  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
-  -->
-
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
-    android:width="24dp"
-    android:height="24dp"
-    android:tint="?colorSecondary"
-    android:viewportWidth="24.0"
-    android:viewportHeight="24.0">
-    <path
-        android:name="p"
-        android:fillColor="#FF000000"
-        android:pathData="M16.59,8.59L12,13.17 7.41,8.59 6,10l6,6 6,-6z" />
-</vector>
diff --git a/app/src/main/res/drawable-anydpi/ic_baseline_auto_graph_24.xml b/app/src/main/res/drawable-anydpi/ic_baseline_auto_graph_24.xml
new file mode 100644 (file)
index 0000000..523c7a5
--- /dev/null
@@ -0,0 +1,31 @@
+<!--
+  ~ Copyright Google Inc.
+  ~
+  ~ Licensed under the Apache License, version 2.0 ("the License");
+  ~ you may not use this file except in compliance with the License.
+  ~ You may obtain a copy of the license at:
+  ~
+  ~ https://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distribution under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  ~
+  ~ Modified/adapted by Damyan Ivanov for MoLe
+  -->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:autoMirrored="true"
+    android:tint="?colorPrimary"
+    android:viewportWidth="24"
+    android:viewportHeight="24"
+    >
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M14.06,9.94L12,9l2.06,-0.94L15,6l0.94,2.06L18,9l-2.06,0.94L15,12L14.06,9.94zM4,14l0.94,-2.06L7,11l-2.06,-0.94L4,8l-0.94,2.06L1,11l2.06,0.94L4,14zM8.5,9l1.09,-2.41L12,5.5L9.59,4.41L8.5,2L7.41,4.41L5,5.5l2.41,1.09L8.5,9zM4.5,20.5l6,-6.01l4,4L23,8.93l-1.41,-1.41l-7.09,7.97l-4,-4L3,19L4.5,20.5z"
+        />
+</vector>
diff --git a/app/src/main/res/drawable-anydpi/ic_baseline_backup_24.xml b/app/src/main/res/drawable-anydpi/ic_baseline_backup_24.xml
new file mode 100644 (file)
index 0000000..1991891
--- /dev/null
@@ -0,0 +1,31 @@
+<!--
+  ~ Copyright Google Inc.
+  ~
+  ~ Licensed under the Apache License, version 2.0 ("the License");
+  ~ you may not use this file except in compliance with the License.
+  ~ You may obtain a copy of the license at:
+  ~
+  ~ https://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distribution under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  ~
+  ~ Modified/adapted by Damyan Ivanov for MoLe
+  -->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:autoMirrored="true"
+    android:tint="?colorPrimary"
+    android:viewportWidth="24"
+    android:viewportHeight="24"
+    >
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M19.35,10.04C18.67,6.59 15.64,4 12,4 9.11,4 6.6,5.64 5.35,8.04 2.34,8.36 0,10.91 0,14c0,3.31 2.69,6 6,6h13c2.76,0 5,-2.24 5,-5 0,-2.64 -2.05,-4.78 -4.65,-4.96zM14,13v4h-4v-4H7l5,-5 5,5h-3z"
+        />
+</vector>
diff --git a/app/src/main/res/drawable-anydpi/ic_baseline_drag_handle_24.xml b/app/src/main/res/drawable-anydpi/ic_baseline_drag_handle_24.xml
new file mode 100644 (file)
index 0000000..49e9a5e
--- /dev/null
@@ -0,0 +1,31 @@
+<!--
+  ~ Copyright Google Inc.
+  ~
+  ~ Licensed under the Apache License, version 2.0 ("the License");
+  ~ you may not use this file except in compliance with the License.
+  ~ You may obtain a copy of the license at:
+  ~
+  ~ https://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distribution under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  ~
+  ~ Modified/adapted by Damyan Ivanov for MoLe
+  -->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:autoMirrored="true"
+    android:tint="?colorPrimary"
+    android:viewportWidth="24"
+    android:viewportHeight="24"
+    >
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M20,9H4v2h16V9zM4,15h16v-2H4V15z"
+        />
+</vector>
diff --git a/app/src/main/res/drawable-anydpi/ic_baseline_help_24_white.xml b/app/src/main/res/drawable-anydpi/ic_baseline_help_24_white.xml
new file mode 100644 (file)
index 0000000..c77419a
--- /dev/null
@@ -0,0 +1,31 @@
+<!--
+  ~ Copyright Google Inc.
+  ~
+  ~ Licensed under the Apache License, version 2.0 ("the License");
+  ~ you may not use this file except in compliance with the License.
+  ~ You may obtain a copy of the license at:
+  ~
+  ~ https://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distribution under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  ~
+  ~ Modified/adapted by Damyan Ivanov for MoLe
+  -->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:autoMirrored="true"
+    android:tint="?colorOnPrimary"
+    android:viewportWidth="24"
+    android:viewportHeight="24"
+    >
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,19h-2v-2h2v2zM15.07,11.25l-0.9,0.92C13.45,12.9 13,13.5 13,15h-2v-0.5c0,-1.1 0.45,-2.1 1.17,-2.83l1.24,-1.26c0.37,-0.36 0.59,-0.86 0.59,-1.41 0,-1.1 -0.9,-2 -2,-2s-2,0.9 -2,2L8,9c0,-2.21 1.79,-4 4,-4s4,1.79 4,4c0,0.88 -0.36,1.68 -0.93,2.25z"
+        />
+</vector>
diff --git a/app/src/main/res/drawable-anydpi/ic_baseline_help_outline_24_primary.xml b/app/src/main/res/drawable-anydpi/ic_baseline_help_outline_24_primary.xml
new file mode 100644 (file)
index 0000000..e67a7ec
--- /dev/null
@@ -0,0 +1,31 @@
+<!--
+  ~ Copyright Google Inc.
+  ~
+  ~ Licensed under the Apache License, version 2.0 ("the License");
+  ~ you may not use this file except in compliance with the License.
+  ~ You may obtain a copy of the license at:
+  ~
+  ~ https://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distribution under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  ~
+  ~ Modified/adapted by Damyan Ivanov for MoLe
+  -->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:autoMirrored="true"
+    android:tint="?colorPrimary"
+    android:viewportWidth="24"
+    android:viewportHeight="24"
+    >
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M11,18h2v-2h-2v2zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8zM12,6c-2.21,0 -4,1.79 -4,4h2c0,-1.1 0.9,-2 2,-2s2,0.9 2,2c0,2 -3,1.75 -3,5h2c0,-2.25 3,-2.5 3,-5 0,-2.21 -1.79,-4 -4,-4z"
+        />
+</vector>
diff --git a/app/src/main/res/drawable-anydpi/ic_baseline_import_export_24.xml b/app/src/main/res/drawable-anydpi/ic_baseline_import_export_24.xml
new file mode 100644 (file)
index 0000000..04dc3a5
--- /dev/null
@@ -0,0 +1,31 @@
+<!--
+  ~ Copyright Google Inc.
+  ~
+  ~ Licensed under the Apache License, version 2.0 ("the License");
+  ~ you may not use this file except in compliance with the License.
+  ~ You may obtain a copy of the license at:
+  ~
+  ~ https://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distribution under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  ~
+  ~ Modified/adapted by Damyan Ivanov for MoLe
+  -->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:autoMirrored="true"
+    android:tint="?colorPrimary"
+    android:viewportWidth="24"
+    android:viewportHeight="24"
+    >
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M9,3L5,6.99h3L8,14h2L10,6.99h3L9,3zM16,17.01L16,10h-2v7.01h-3L15,21l4,-3.99h-3z"
+        />
+</vector>
diff --git a/app/src/main/res/drawable-anydpi/ic_baseline_qr_code_scanner_24.xml b/app/src/main/res/drawable-anydpi/ic_baseline_qr_code_scanner_24.xml
new file mode 100644 (file)
index 0000000..7c32f10
--- /dev/null
@@ -0,0 +1,31 @@
+<!--
+  ~ Copyright Google Inc.
+  ~
+  ~ Licensed under the Apache License, version 2.0 ("the License");
+  ~ you may not use this file except in compliance with the License.
+  ~ You may obtain a copy of the license at:
+  ~
+  ~ https://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distribution under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  ~
+  ~ Modified/adapted by Damyan Ivanov for MoLe
+  -->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:autoMirrored="true"
+    android:tint="#EEEEEE"
+    android:viewportWidth="24"
+    android:viewportHeight="24"
+    >
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M9.5,6.5v3h-3v-3H9.5M11,5H5v6h6V5L11,5zM9.5,14.5v3h-3v-3H9.5M11,13H5v6h6V13L11,13zM17.5,6.5v3h-3v-3H17.5M19,5h-6v6h6V5L19,5zM13,13h1.5v1.5H13V13zM14.5,14.5H16V16h-1.5V14.5zM16,13h1.5v1.5H16V13zM13,16h1.5v1.5H13V16zM14.5,17.5H16V19h-1.5V17.5zM16,16h1.5v1.5H16V16zM17.5,14.5H19V16h-1.5V14.5zM17.5,17.5H19V19h-1.5V17.5zM22,7h-2V4h-3V2h5V7zM22,22v-5h-2v3h-3v2H22zM2,22h5v-2H4v-3H2V22zM2,2v5h2V4h3V2H2z"
+        />
+</vector>
diff --git a/app/src/main/res/drawable-anydpi/ic_baseline_restore_24.xml b/app/src/main/res/drawable-anydpi/ic_baseline_restore_24.xml
new file mode 100644 (file)
index 0000000..b006f61
--- /dev/null
@@ -0,0 +1,31 @@
+<!--
+  ~ Copyright Google Inc.
+  ~
+  ~ Licensed under the Apache License, version 2.0 ("the License");
+  ~ you may not use this file except in compliance with the License.
+  ~ You may obtain a copy of the license at:
+  ~
+  ~ https://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distribution under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  ~
+  ~ Modified/adapted by Damyan Ivanov for MoLe
+  -->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:autoMirrored="true"
+    android:tint="?colorPrimary"
+    android:viewportWidth="24"
+    android:viewportHeight="24"
+    >
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M13,3c-4.97,0 -9,4.03 -9,9L1,12l3.89,3.89 0.07,0.14L9,12L6,12c0,-3.87 3.13,-7 7,-7s7,3.13 7,7 -3.13,7 -7,7c-1.93,0 -3.68,-0.79 -4.94,-2.06l-1.42,1.42C8.27,19.99 10.51,21 13,21c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,8v5l4.28,2.54 0.72,-1.21 -3.5,-2.08L13.5,8L12,8z"
+        />
+</vector>
diff --git a/app/src/main/res/drawable-anydpi/ic_cancel_white_24dp.xml b/app/src/main/res/drawable-anydpi/ic_cancel_white_24dp.xml
deleted file mode 100644 (file)
index 0a792f4..0000000
+++ /dev/null
@@ -1,23 +0,0 @@
-<!--
-  ~ Copyright Google Inc.
-  ~
-  ~ Licensed under the Apache License, version 2.0 ("the License");
-  ~ you may not use this file except in compliance with the License.
-  ~ You may obtain a copy of the license at:
-  ~
-  ~ https://www.apache.org/licenses/LICENSE-2.0
-  ~
-  ~ Unless required by applicable law or agreed to in writing, software
-  ~ distribution under the License is distributed on an "AS IS" BASIS,
-  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-  ~ See the License for the specific language governing permissions and
-  ~ limitations under the License.
-  ~
-  ~ Modified/adapted by Damyan Ivanov for MoLe
-  -->
-
-<vector android:height="24dp" android:tint="#EEEEEE"
-    android:viewportHeight="24.0" android:viewportWidth="24.0"
-    android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
-    <path android:fillColor="#FF000000" android:pathData="M12,2C6.47,2 2,6.47 2,12s4.47,10 10,10 10,-4.47 10,-10S17.53,2 12,2zM17,15.59L15.59,17 12,13.41 8.41,17 7,15.59 10.59,12 7,8.41 8.41,7 12,10.59 15.59,7 17,8.41 13.41,12 17,15.59z"/>
-</vector>
diff --git a/app/src/main/res/drawable-anydpi/ic_check_white_24dp.xml b/app/src/main/res/drawable-anydpi/ic_check_white_24dp.xml
deleted file mode 100644 (file)
index 709f8ac..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-<!--
-  ~ Copyright Google Inc.
-  ~
-  ~ Licensed under the Apache License, version 2.0 ("the License");
-  ~ you may not use this file except in compliance with the License.
-  ~ You may obtain a copy of the license at:
-  ~
-  ~ https://www.apache.org/licenses/LICENSE-2.0
-  ~
-  ~ Unless required by applicable law or agreed to in writing, software
-  ~ distribution under the License is distributed on an "AS IS" BASIS,
-  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-  ~ See the License for the specific language governing permissions and
-  ~ limitations under the License.
-  ~
-  ~ Modified/adapted by Damyan Ivanov for MoLe
-  -->
-
-
-<vector android:height="24dp" android:tint="#EEEEEE"
-    android:viewportHeight="24.0" android:viewportWidth="24.0"
-    android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
-    <path android:fillColor="#FF000000" android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z"/>
-</vector>
diff --git a/app/src/main/res/drawable-anydpi/ic_comment_black_24dp.xml b/app/src/main/res/drawable-anydpi/ic_comment_black_24dp.xml
deleted file mode 100644 (file)
index 60588c1..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-<!--
-  ~ Copyright Google Inc.
-  ~
-  ~ Licensed under the Apache License, version 2.0 ("the License");
-  ~ you may not use this file except in compliance with the License.
-  ~ You may obtain a copy of the license at:
-  ~
-  ~ https://www.apache.org/licenses/LICENSE-2.0
-  ~
-  ~ Unless required by applicable law or agreed to in writing, software
-  ~ distribution under the License is distributed on an "AS IS" BASIS,
-  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-  ~ See the License for the specific language governing permissions and
-  ~ limitations under the License.
-  ~
-  ~ Modified/adapted by Damyan Ivanov for MoLe
-  -->
-
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
-    android:width="24dp"
-    android:height="24dp"
-    android:autoMirrored="true"
-    android:tint="?colorPrimary"
-    android:viewportWidth="24.0"
-    android:viewportHeight="24.0">
-    <path
-        android:fillColor="#FF000000"
-        android:pathData="M21.99,4c0,-1.1 -0.89,-2 -1.99,-2L4,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h14l4,4 -0.01,-18zM18,14L6,14v-2h12v2zM18,11L6,11L6,9h12v2zM18,8L6,8L6,6h12v2z" />
-</vector>
diff --git a/app/src/main/res/drawable-anydpi/ic_event_primary_24dp.xml b/app/src/main/res/drawable-anydpi/ic_event_primary_24dp.xml
deleted file mode 100644 (file)
index 48b0450..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-<!--
-  ~ Copyright Google Inc.
-  ~
-  ~ Licensed under the Apache License, version 2.0 ("the License");
-  ~ you may not use this file except in compliance with the License.
-  ~ You may obtain a copy of the license at:
-  ~
-  ~ https://www.apache.org/licenses/LICENSE-2.0
-  ~
-  ~ Unless required by applicable law or agreed to in writing, software
-  ~ distribution under the License is distributed on an "AS IS" BASIS,
-  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-  ~ See the License for the specific language governing permissions and
-  ~ limitations under the License.
-  ~
-  ~ Modified/adapted by Damyan Ivanov for MoLe
-  -->
-
-<vector android:height="24dp"
-    android:viewportHeight="24.0" android:viewportWidth="24.0"
-    android:tint="?colorPrimary"
-    android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
-    <path android:fillColor="#FF000000" android:pathData="M17,12h-5v5h5v-5zM16,1v2L8,3L8,1L6,1v2L5,3c-1.11,0 -1.99,0.9 -1.99,2L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2L21,5c0,-1.1 -0.9,-2 -2,-2h-1L18,1h-2zM19,19L5,19L5,8h14v11z"/>
-</vector>
diff --git a/app/src/main/res/drawable-anydpi/ic_exit_to_app_black_24dp.xml b/app/src/main/res/drawable-anydpi/ic_exit_to_app_black_24dp.xml
deleted file mode 100644 (file)
index 8c390b3..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-<!--
-  ~ Copyright Google Inc.
-  ~
-  ~ Licensed under the Apache License, version 2.0 ("the License");
-  ~ you may not use this file except in compliance with the License.
-  ~ You may obtain a copy of the license at:
-  ~
-  ~ https://www.apache.org/licenses/LICENSE-2.0
-  ~
-  ~ Unless required by applicable law or agreed to in writing, software
-  ~ distribution under the License is distributed on an "AS IS" BASIS,
-  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-  ~ See the License for the specific language governing permissions and
-  ~ limitations under the License.
-  ~
-  ~ Modified/adapted by Damyan Ivanov for MoLe
-  -->
-
-
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
-    android:width="24dp"
-    android:height="24dp"
-    android:tint="#313131"
-    android:viewportWidth="24.0"
-    android:viewportHeight="24.0">
-    <path
-        android:fillColor="#FF000000"
-        android:pathData="M10.09,15.59L11.5,17l5,-5 -5,-5 -1.41,1.41L12.67,11H3v2h9.67l-2.58,2.59zM19,3H5c-1.11,0 -2,0.9 -2,2v4h2V5h14v14H5v-4H3v4c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2z" />
-</vector>
diff --git a/app/src/main/res/drawable-anydpi/ic_info_black_24dp.xml b/app/src/main/res/drawable-anydpi/ic_info_black_24dp.xml
deleted file mode 100644 (file)
index 09fd157..0000000
+++ /dev/null
@@ -1,28 +0,0 @@
-<!--
-  ~ Copyright Google Inc.
-  ~
-  ~ Licensed under the Apache License, version 2.0 ("the License");
-  ~ you may not use this file except in compliance with the License.
-  ~ You may obtain a copy of the license at:
-  ~
-  ~ https://www.apache.org/licenses/LICENSE-2.0
-  ~
-  ~ Unless required by applicable law or agreed to in writing, software
-  ~ distribution under the License is distributed on an "AS IS" BASIS,
-  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-  ~ See the License for the specific language governing permissions and
-  ~ limitations under the License.
-  ~
-  ~ Modified/adapted by Damyan Ivanov for MoLe
-  -->
-
-
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
-    android:width="24dp"
-    android:height="24dp"
-    android:viewportWidth="24.0"
-    android:viewportHeight="24.0">
-    <path
-        android:fillColor="#FF000000"
-        android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zm1,15h-2v-6h2v6zm0,-8h-2V7h2v2z" />
-</vector>
diff --git a/app/src/main/res/drawable-anydpi/ic_keyboard_arrow_down_black_24dp.xml b/app/src/main/res/drawable-anydpi/ic_keyboard_arrow_down_black_24dp.xml
deleted file mode 100644 (file)
index d52eacf..0000000
+++ /dev/null
@@ -1,23 +0,0 @@
-<!--
-  ~ Copyright Google Inc.
-  ~
-  ~ Licensed under the Apache License, version 2.0 ("the License");
-  ~ you may not use this file except in compliance with the License.
-  ~ You may obtain a copy of the license at:
-  ~
-  ~ https://www.apache.org/licenses/LICENSE-2.0
-  ~
-  ~ Unless required by applicable law or agreed to in writing, software
-  ~ distribution under the License is distributed on an "AS IS" BASIS,
-  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-  ~ See the License for the specific language governing permissions and
-  ~ limitations under the License.
-  ~
-  ~ Modified/adapted by Damyan Ivanov for MoLe
-  -->
-
-<vector android:height="24dp" android:tint="?colorPrimary"
-    android:viewportHeight="24.0" android:viewportWidth="24.0"
-    android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
-    <path android:fillColor="#FF000000" android:pathData="M7.41,7.84L12,12.42l4.59,-4.58L18,9.25l-6,6 -6,-6z"/>
-</vector>
diff --git a/app/src/main/res/drawable-anydpi/ic_menu_manage.xml b/app/src/main/res/drawable-anydpi/ic_menu_manage.xml
deleted file mode 100644 (file)
index 830aa28..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-<!--
-  ~ Copyright Google Inc.
-  ~
-  ~ Licensed under the Apache License, version 2.0 ("the License");
-  ~ you may not use this file except in compliance with the License.
-  ~ You may obtain a copy of the license at:
-  ~
-  ~ https://www.apache.org/licenses/LICENSE-2.0
-  ~
-  ~ Unless required by applicable law or agreed to in writing, software
-  ~ distribution under the License is distributed on an "AS IS" BASIS,
-  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-  ~ See the License for the specific language governing permissions and
-  ~ limitations under the License.
-  ~
-  ~ Modified/adapted by Damyan Ivanov for MoLe
-  -->
-
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
-    android:width="24dp"
-    android:height="24dp"
-    android:viewportWidth="24.0"
-    android:viewportHeight="24.0">
-    <path
-        android:fillColor="#FF000000"
-        android:pathData="M22.7,19l-9.1,-9.1c0.9,-2.3 0.4,-5 -1.5,-6.9 -2,-2 -5,-2.4 -7.4,-1.3L9,6 6,9 1.6,4.7C0.4,7.1 0.9,10.1 2.9,12.1c1.9,1.9 4.6,2.4 6.9,1.5l9.1,9.1c0.4,0.4 1,0.4 1.4,0l2.3,-2.3c0.5,-0.4 0.5,-1.1 0.1,-1.4z" />
-</vector>
\ No newline at end of file
diff --git a/app/src/main/res/drawable-anydpi/ic_menu_send.xml b/app/src/main/res/drawable-anydpi/ic_menu_send.xml
deleted file mode 100644 (file)
index 476e674..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-<!--
-  ~ Copyright Google Inc.
-  ~
-  ~ Licensed under the Apache License, version 2.0 ("the License");
-  ~ you may not use this file except in compliance with the License.
-  ~ You may obtain a copy of the license at:
-  ~
-  ~ https://www.apache.org/licenses/LICENSE-2.0
-  ~
-  ~ Unless required by applicable law or agreed to in writing, software
-  ~ distribution under the License is distributed on an "AS IS" BASIS,
-  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-  ~ See the License for the specific language governing permissions and
-  ~ limitations under the License.
-  ~
-  ~ Modified/adapted by Damyan Ivanov for MoLe
-  -->
-
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
-    android:width="24dp"
-    android:height="24dp"
-    android:viewportWidth="24.0"
-    android:viewportHeight="24.0">
-    <path
-        android:fillColor="#FF000000"
-        android:pathData="M2.01,21L23,12 2.01,3 2,10l15,2 -15,2z" />
-</vector>
diff --git a/app/src/main/res/drawable-anydpi/ic_menu_share.xml b/app/src/main/res/drawable-anydpi/ic_menu_share.xml
deleted file mode 100644 (file)
index f83b2ec..0000000
+++ /dev/null
@@ -1,28 +0,0 @@
-<!--
-  ~ Copyright Google Inc.
-  ~
-  ~ Licensed under the Apache License, version 2.0 ("the License");
-  ~ you may not use this file except in compliance with the License.
-  ~ You may obtain a copy of the license at:
-  ~
-  ~ https://www.apache.org/licenses/LICENSE-2.0
-  ~
-  ~ Unless required by applicable law or agreed to in writing, software
-  ~ distribution under the License is distributed on an "AS IS" BASIS,
-  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-  ~ See the License for the specific language governing permissions and
-  ~ limitations under the License.
-  ~
-  ~ Modified/adapted by Damyan Ivanov for MoLe
-  -->
-
-
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
-    android:width="24dp"
-    android:height="24dp"
-    android:viewportWidth="24.0"
-    android:viewportHeight="24.0">
-    <path
-        android:fillColor="#FF000000"
-        android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92 1.61,0 2.92,-1.31 2.92,-2.92s-1.31,-2.92 -2.92,-2.92z" />
-</vector>
diff --git a/app/src/main/res/drawable-anydpi/ic_more_horiz_black_24dp.xml b/app/src/main/res/drawable-anydpi/ic_more_horiz_black_24dp.xml
deleted file mode 100644 (file)
index 3d13250..0000000
+++ /dev/null
@@ -1,30 +0,0 @@
-<!--
-  ~ Copyright Google Inc.
-  ~
-  ~ Licensed under the Apache License, version 2.0 ("the License");
-  ~ you may not use this file except in compliance with the License.
-  ~ You may obtain a copy of the license at:
-  ~
-  ~ https://www.apache.org/licenses/LICENSE-2.0
-  ~
-  ~ Unless required by applicable law or agreed to in writing, software
-  ~ distribution under the License is distributed on an "AS IS" BASIS,
-  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-  ~ See the License for the specific language governing permissions and
-  ~ limitations under the License.
-  ~
-  ~ Modified/adapted by Damyan Ivanov for MoLe
-  -->
-
-<vector android:height="24dp"
-    android:tint="?colorSecondary"
-    android:viewportHeight="24.0"
-    android:viewportWidth="24.0"
-    android:width="24dp"
-    xmlns:android="http://schemas.android.com/apk/res/android"
-    >
-    <path
-        android:fillColor="#FF000000"
-        android:pathData="M6,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM18,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z"
-        />
-</vector>
diff --git a/app/src/main/res/drawable-anydpi/ic_notifications_black_24dp.xml b/app/src/main/res/drawable-anydpi/ic_notifications_black_24dp.xml
deleted file mode 100644 (file)
index dcbcf36..0000000
+++ /dev/null
@@ -1,28 +0,0 @@
-<!--
-  ~ Copyright Google Inc.
-  ~
-  ~ Licensed under the Apache License, version 2.0 ("the License");
-  ~ you may not use this file except in compliance with the License.
-  ~ You may obtain a copy of the license at:
-  ~
-  ~ https://www.apache.org/licenses/LICENSE-2.0
-  ~
-  ~ Unless required by applicable law or agreed to in writing, software
-  ~ distribution under the License is distributed on an "AS IS" BASIS,
-  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-  ~ See the License for the specific language governing permissions and
-  ~ limitations under the License.
-  ~
-  ~ Modified/adapted by Damyan Ivanov for MoLe
-  -->
-
-
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
-    android:width="24dp"
-    android:height="24dp"
-    android:viewportWidth="24.0"
-    android:viewportHeight="24.0">
-    <path
-        android:fillColor="#FF000000"
-        android:pathData="M11.5,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.9,2 2,2zm6.5,-6v-5.5c0,-3.07 -2.13,-5.64 -5,-6.32V3.5c0,-0.83 -0.67,-1.5 -1.5,-1.5S10,2.67 10,3.5v0.68c-2.87,0.68 -5,3.25 -5,6.32V16l-2,2v1h17v-1l-2,-2z" />
-</vector>
diff --git a/app/src/main/res/drawable-anydpi/ic_refresh_primary_24dp.xml b/app/src/main/res/drawable-anydpi/ic_refresh_primary_24dp.xml
deleted file mode 100644 (file)
index 4ddd835..0000000
+++ /dev/null
@@ -1,31 +0,0 @@
-<!--
-  ~ Copyright Google Inc.
-  ~
-  ~ Licensed under the Apache License, version 2.0 ("the License");
-  ~ you may not use this file except in compliance with the License.
-  ~ You may obtain a copy of the license at:
-  ~
-  ~ https://www.apache.org/licenses/LICENSE-2.0
-  ~
-  ~ Unless required by applicable law or agreed to in writing, software
-  ~ distribution under the License is distributed on an "AS IS" BASIS,
-  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-  ~ See the License for the specific language governing permissions and
-  ~ limitations under the License.
-  ~
-  ~ Modified/adapted by Damyan Ivanov for MoLe
-  -->
-
-
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
-    android:width="24dp"
-    android:height="24dp"
-    android:tint="?attr/colorPrimary"
-    android:viewportWidth="24.0"
-    android:viewportHeight="24.0"
-    >
-    <path
-        android:fillColor="#FF000000"
-        android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"
-        />
-</vector>
diff --git a/app/src/main/res/drawable-anydpi/ic_star_black_24dp.xml b/app/src/main/res/drawable-anydpi/ic_star_black_24dp.xml
deleted file mode 100644 (file)
index 276471f..0000000
+++ /dev/null
@@ -1,23 +0,0 @@
-<!--
-  ~ Copyright Google Inc.
-  ~
-  ~ Licensed under the Apache License, version 2.0 ("the License");
-  ~ you may not use this file except in compliance with the License.
-  ~ You may obtain a copy of the license at:
-  ~
-  ~ https://www.apache.org/licenses/LICENSE-2.0
-  ~
-  ~ Unless required by applicable law or agreed to in writing, software
-  ~ distribution under the License is distributed on an "AS IS" BASIS,
-  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-  ~ See the License for the specific language governing permissions and
-  ~ limitations under the License.
-  ~
-  ~ Modified/adapted by Damyan Ivanov for MoLe
-  -->
-
-<vector android:height="24dp" android:tint="#313131"
-    android:viewportHeight="24.0" android:viewportWidth="24.0"
-    android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
-    <path android:fillColor="#FF000000" android:pathData="M12,17.27L18.18,21l-1.64,-7.03L22,9.24l-7.19,-0.61L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21z"/>
-</vector>
diff --git a/app/src/main/res/drawable-anydpi/ic_star_border_black_24dp.xml b/app/src/main/res/drawable-anydpi/ic_star_border_black_24dp.xml
deleted file mode 100644 (file)
index 3dae60a..0000000
+++ /dev/null
@@ -1,23 +0,0 @@
-<!--
-  ~ Copyright Google Inc.
-  ~
-  ~ Licensed under the Apache License, version 2.0 ("the License");
-  ~ you may not use this file except in compliance with the License.
-  ~ You may obtain a copy of the license at:
-  ~
-  ~ https://www.apache.org/licenses/LICENSE-2.0
-  ~
-  ~ Unless required by applicable law or agreed to in writing, software
-  ~ distribution under the License is distributed on an "AS IS" BASIS,
-  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-  ~ See the License for the specific language governing permissions and
-  ~ limitations under the License.
-  ~
-  ~ Modified/adapted by Damyan Ivanov for MoLe
-  -->
-
-<vector android:height="24dp" android:tint="#313131"
-    android:viewportHeight="24.0" android:viewportWidth="24.0"
-    android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
-    <path android:fillColor="#FF000000" android:pathData="M22,9.24l-7.19,-0.62L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21 12,17.27 18.18,21l-1.63,-7.03L22,9.24zM12,15.4l-3.76,2.27 1,-4.28 -3.32,-2.88 4.38,-0.38L12,6.1l1.71,4.04 4.38,0.38 -3.32,2.88 1,4.28L12,15.4z"/>
-</vector>
diff --git a/app/src/main/res/drawable-anydpi/ic_star_border_white_24dp.xml b/app/src/main/res/drawable-anydpi/ic_star_border_white_24dp.xml
deleted file mode 100644 (file)
index 77acbe0..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-<!--
-  ~ Copyright Google Inc.
-  ~
-  ~ Licensed under the Apache License, version 2.0 ("the License");
-  ~ you may not use this file except in compliance with the License.
-  ~ You may obtain a copy of the license at:
-  ~
-  ~ https://www.apache.org/licenses/LICENSE-2.0
-  ~
-  ~ Unless required by applicable law or agreed to in writing, software
-  ~ distribution under the License is distributed on an "AS IS" BASIS,
-  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-  ~ See the License for the specific language governing permissions and
-  ~ limitations under the License.
-  ~
-  ~ Modified/adapted by Damyan Ivanov for MoLe
-  -->
-
-
-<vector android:height="24dp" android:tint="#EEEEEE"
-    android:viewportHeight="24.0" android:viewportWidth="24.0"
-    android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
-    <path android:fillColor="#FF000000" android:pathData="M22,9.24l-7.19,-0.62L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21 12,17.27 18.18,21l-1.63,-7.03L22,9.24zM12,15.4l-3.76,2.27 1,-4.28 -3.32,-2.88 4.38,-0.38L12,6.1l1.71,4.04 4.38,0.38 -3.32,2.88 1,4.28L12,15.4z"/>
-</vector>
diff --git a/app/src/main/res/drawable-anydpi/ic_star_white_24dp.xml b/app/src/main/res/drawable-anydpi/ic_star_white_24dp.xml
deleted file mode 100644 (file)
index b3c879e..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-<!--
-  ~ Copyright Google Inc.
-  ~
-  ~ Licensed under the Apache License, version 2.0 ("the License");
-  ~ you may not use this file except in compliance with the License.
-  ~ You may obtain a copy of the license at:
-  ~
-  ~ https://www.apache.org/licenses/LICENSE-2.0
-  ~
-  ~ Unless required by applicable law or agreed to in writing, software
-  ~ distribution under the License is distributed on an "AS IS" BASIS,
-  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-  ~ See the License for the specific language governing permissions and
-  ~ limitations under the License.
-  ~
-  ~ Modified/adapted by Damyan Ivanov for MoLe
-  -->
-
-
-<vector android:height="24dp" android:tint="#EEEEEE"
-    android:viewportHeight="24.0" android:viewportWidth="24.0"
-    android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
-    <path android:fillColor="#FF000000" android:pathData="M12,17.27L18.18,21l-1.64,-7.03L22,9.24l-7.19,-0.61L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21z"/>
-</vector>
diff --git a/app/src/main/res/drawable-anydpi/ic_sync_black_24dp.xml b/app/src/main/res/drawable-anydpi/ic_sync_black_24dp.xml
deleted file mode 100644 (file)
index 3ec178d..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-<!--
-  ~ Copyright Google Inc.
-  ~
-  ~ Licensed under the Apache License, version 2.0 ("the License");
-  ~ you may not use this file except in compliance with the License.
-  ~ You may obtain a copy of the license at:
-  ~
-  ~ https://www.apache.org/licenses/LICENSE-2.0
-  ~
-  ~ Unless required by applicable law or agreed to in writing, software
-  ~ distribution under the License is distributed on an "AS IS" BASIS,
-  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-  ~ See the License for the specific language governing permissions and
-  ~ limitations under the License.
-  ~
-  ~ Modified/adapted by Damyan Ivanov for MoLe
-  -->
-
-
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
-    android:width="24dp"
-    android:height="24dp"
-    android:viewportWidth="24.0"
-    android:viewportHeight="24.0">
-    <path
-        android:fillColor="#FF000000"
-        android:pathData="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01,-0.25 1.97,-0.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0,-4.42,-3.58,-8,-8,-8zm0 14c-3.31 0,-6,-2.69,-6,-6 0,-1.01 0.25,-1.97 0.7,-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4,-4,-4,-4v3z"
-        />
-</vector>
\ No newline at end of file
diff --git a/app/src/main/res/drawable-anydpi/ic_thick_check_white.xml b/app/src/main/res/drawable-anydpi/ic_thick_check_white.xml
deleted file mode 100644 (file)
index f427e07..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-<!--
-  ~ Copyright Google Inc.
-  ~
-  ~ Licensed under the Apache License, version 2.0 ("the License");
-  ~ you may not use this file except in compliance with the License.
-  ~ You may obtain a copy of the license at:
-  ~
-  ~ https://www.apache.org/licenses/LICENSE-2.0
-  ~
-  ~ Unless required by applicable law or agreed to in writing, software
-  ~ distribution under the License is distributed on an "AS IS" BASIS,
-  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-  ~ See the License for the specific language governing permissions and
-  ~ limitations under the License.
-  ~
-  ~ Modified/adapted by Damyan Ivanov for MoLe
-  -->
-
-
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
-    android:width="24dp"
-    android:height="24dp"
-    android:viewportWidth="6.35"
-    android:viewportHeight="6.3500004">
-  <path
-      android:pathData="m5.72461,0.0004a0.43254,0.43254 0,0 0,-0.37109 0.23828L2.79688,5.14102 0.94531,2.91837A0.43254,0.43254 0,1 0,0.28125 3.4711l2.26563,2.72266a0.43254,0.43254 0,0 0,0.7168 -0.0762l2.85742,-5.48047a0.43254,0.43254 0,0 0,-0.39648 -0.63672z"
-      android:strokeAlpha="1"
-      android:strokeLineJoin="round"
-      android:strokeWidth="0.86500001"
-      android:fillColor="#ffffff"
-      android:strokeColor="#00000000"
-      android:fillType="evenOdd"
-      android:fillAlpha="1"
-      android:strokeLineCap="round"/>
-</vector>
diff --git a/app/src/main/res/drawable-anydpi/ic_unfold_more_black_24dp.xml b/app/src/main/res/drawable-anydpi/ic_unfold_more_black_24dp.xml
deleted file mode 100644 (file)
index ca6b180..0000000
+++ /dev/null
@@ -1,30 +0,0 @@
-<!--
-  ~ Copyright Google Inc.
-  ~
-  ~ Licensed under the Apache License, version 2.0 ("the License");
-  ~ you may not use this file except in compliance with the License.
-  ~ You may obtain a copy of the license at:
-  ~
-  ~ https://www.apache.org/licenses/LICENSE-2.0
-  ~
-  ~ Unless required by applicable law or agreed to in writing, software
-  ~ distribution under the License is distributed on an "AS IS" BASIS,
-  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-  ~ See the License for the specific language governing permissions and
-  ~ limitations under the License.
-  ~
-  ~ Modified/adapted by Damyan Ivanov for MoLe
-  -->
-
-<vector android:height="24dp"
-    android:tint="?colorSecondary"
-    android:viewportHeight="24.0"
-    android:viewportWidth="24.0"
-    android:width="24dp"
-    xmlns:android="http://schemas.android.com/apk/res/android"
-    >
-    <path
-        android:fillColor="#FF000000"
-        android:pathData="M12,5.83L15.17,9l1.41,-1.41L12,3 7.41,7.59 8.83,9 12,5.83zM12,18.17L8.83,15l-1.41,1.41L12,21l4.59,-4.59L15.17,15 12,18.17z"
-        />
-</vector>
diff --git a/app/src/main/res/drawable-anydpi/ic_view_list_black_24dp.xml b/app/src/main/res/drawable-anydpi/ic_view_list_black_24dp.xml
deleted file mode 100644 (file)
index df6bcb0..0000000
+++ /dev/null
@@ -1,28 +0,0 @@
-<!--
-  ~ Copyright Google Inc.
-  ~
-  ~ Licensed under the Apache License, version 2.0 ("the License");
-  ~ you may not use this file except in compliance with the License.
-  ~ You may obtain a copy of the license at:
-  ~
-  ~ https://www.apache.org/licenses/LICENSE-2.0
-  ~
-  ~ Unless required by applicable law or agreed to in writing, software
-  ~ distribution under the License is distributed on an "AS IS" BASIS,
-  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-  ~ See the License for the specific language governing permissions and
-  ~ limitations under the License.
-  ~
-  ~ Modified/adapted by Damyan Ivanov for MoLe
-  -->
-
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
-    android:width="24dp"
-    android:height="24dp"
-    android:tint="?colorSecondary"
-    android:viewportWidth="24.0"
-    android:viewportHeight="24.0">
-    <path
-        android:fillColor="#FF000000"
-        android:pathData="M4,14h4v-4L4,10v4zM4,19h4v-4L4,15v4zM4,9h4L8,5L4,5v4zM9,14h12v-4L9,10v4zM9,19h12v-4L9,15v4zM9,5v4h12L21,5L9,5z" />
-</vector>
diff --git a/app/src/main/res/drawable-anydpi/list_divider_inside_out.xml b/app/src/main/res/drawable-anydpi/list_divider_inside_out.xml
deleted file mode 100644 (file)
index 4e24c17..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!--
-  ~ Copyright © 2020 Damyan Ivanov.
-  ~ This file is part of MoLe.
-  ~ MoLe is free software: you can distribute it and/or modify it
-  ~ under the term of the GNU General Public License as published by
-  ~ the Free Software Foundation, either version 3 of the License, or
-  ~ (at your opinion), any later version.
-  ~
-  ~ MoLe is distributed in the hope that it will be useful,
-  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
-  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-  ~ GNU General Public License terms for details.
-  ~
-  ~ You should have received a copy of the GNU General Public License
-  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
-  -->
-
-<shape xmlns:android="http://schemas.android.com/apk/res/android">
-    <gradient
-        android:type="linear"
-        android:endColor="?colorPrimary"
-        android:centerColor="?colorPrimaryTransparent"
-        android:startColor="?colorPrimary" />
-    <size android:height="1dp" />
-</shape>
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_baseline_calendar_today_24.xml b/app/src/main/res/drawable/ic_baseline_calendar_today_24.xml
deleted file mode 100644 (file)
index 18fcdbc..0000000
+++ /dev/null
@@ -1,31 +0,0 @@
-<!--
-  ~ Copyright Google Inc.
-  ~
-  ~ Licensed under the Apache License, version 2.0 ("the License");
-  ~ you may not use this file except in compliance with the License.
-  ~ You may obtain a copy of the license at:
-  ~
-  ~ https://www.apache.org/licenses/LICENSE-2.0
-  ~
-  ~ Unless required by applicable law or agreed to in writing, software
-  ~ distribution under the License is distributed on an "AS IS" BASIS,
-  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-  ~ See the License for the specific language governing permissions and
-  ~ limitations under the License.
-  ~
-  ~ Modified/adapted by Damyan Ivanov for MoLe
-  -->
-
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
-    android:width="24dp"
-    android:height="24dp"
-    android:autoMirrored="true"
-    android:tint="?colorOnPrimary"
-    android:viewportWidth="24"
-    android:viewportHeight="24"
-    >
-    <path
-        android:fillColor="@android:color/white"
-        android:pathData="M20,3h-1L19,1h-2v2L7,3L7,1L5,1v2L4,3c-1.1,0 -2,0.9 -2,2v16c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,5c0,-1.1 -0.9,-2 -2,-2zM20,21L4,21L4,8h16v13z"
-        />
-</vector>
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
deleted file mode 100644 (file)
index c8c1a98..0000000
+++ /dev/null
@@ -1,59 +0,0 @@
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
-    android:width="108dp"
-    android:height="108dp"
-    android:viewportWidth="108"
-    android:viewportHeight="108">
-  <group android:scaleX="0.73"
-      android:scaleY="0.73"
-      android:translateX="14.58"
-      android:translateY="14.58">
-    <path
-        android:pathData="m42.06,32.596c-1.331,1.331 -1.33,3.49 0.002,4.82 1.331,1.33 3.488,1.329 4.818,-0.002 3.962,-3.962 10.28,-3.962 14.242,0 1.33,1.331 3.487,1.332 4.818,0.002 1.332,-1.33 1.333,-3.488 0.002,-4.82 -7.523,-6.703 -17.464,-6.433 -23.881,0z"
-        android:strokeLineJoin="round"
-        android:strokeWidth="7.1924"
-        android:fillColor="#ffffff"
-        android:strokeColor="#00000000"
-        android:fillType="nonZero"
-        android:strokeLineCap="round"/>
-    <path
-        android:pathData="m32.531,23.62c-1.33,1.33 -1.33,3.487 0,4.818 1.33,1.33 3.487,1.33 4.818,0 9.225,-9.227 24.078,-9.227 33.304,0 1.33,1.33 3.487,1.33 4.818,0 1.33,-1.33 1.33,-3.487 0,-4.818 -12.952,-12.351 -31.975,-11.302 -42.939,0z"
-        android:strokeLineJoin="round"
-        android:strokeWidth="7.1924"
-        android:fillColor="#ffffff"
-        android:strokeColor="#00000000"
-        android:fillType="nonZero"
-        android:strokeLineCap="round"/>
-    <path
-        android:pathData="m17.52,41.916c-3.229,0.666 -3.355,3.178 -3.355,3.827L14.165,89.41c0,2.088 1.746,3.837 3.835,3.837 13.048,-2.199 19.956,-2.121 36,0.007C68.79,91.212 79.587,90.998 90,93.247c2.089,0 3.835,-1.749 3.835,-3.837L93.835,45.743c0,-2.094 -1.814,-3.538 -3.636,-3.827 -9.491,-1.502 -13.338,-2.216 -27.678,-0.945 0.484,1.475 0.72,2.785 0.442,4.184 10.721,-1.146 19.131,-0.668 26.469,0.915l-0.045,42.916C77.902,86.92 70.894,86.96 56.213,88.799L56.213,52.774c-1.422,0.348 -3.104,0.332 -4.426,0L51.787,88.799C40.094,86.929 28.873,86.93 18.591,88.987L18.591,46.07c11.035,-1.397 15.892,-2.046 26.444,-0.915 -0.18,-1.441 0.022,-2.978 0.451,-4.184 -9.086,-1.124 -17.014,-0.646 -27.966,0.945z"
-        android:strokeLineJoin="round"
-        android:strokeWidth="4.42609"
-        android:fillColor="#ffffff"
-        android:strokeColor="#00000000"
-        android:fillType="nonZero"
-        android:strokeLineCap="round"/>
-    <path
-        android:pathData="M32.773,55.429L32.773,65.965L22.238,65.965v5.137h10.534v10.534h5.137L37.91,71.102L48.446,71.102L48.446,65.965L37.91,65.965L37.91,55.429Z"
-        android:strokeLineJoin="miter"
-        android:strokeWidth="5.42196"
-        android:fillColor="#ffffff"
-        android:strokeColor="#00000000"
-        android:fillType="evenOdd"
-        android:strokeLineCap="butt"/>
-    <path
-        android:pathData="M59.352,65.965L59.352,71.102L85.559,71.102v-5.137z"
-        android:strokeLineJoin="miter"
-        android:strokeWidth="5.42196"
-        android:fillColor="#ffffff"
-        android:strokeColor="#00000000"
-        android:fillType="evenOdd"
-        android:strokeLineCap="butt"/>
-    <path
-        android:pathData="m54,40.578c1.764,0 3.407,1.645 3.407,3.407 0,1.762 -1.643,3.403 -3.407,3.403 -1.764,0 -3.407,-1.641 -3.407,-3.403 0,-1.762 1.643,-3.407 3.407,-3.407z"
-        android:strokeLineJoin="round"
-        android:strokeWidth="11.5078"
-        android:fillColor="#ffffff"
-        android:strokeColor="#00000000"
-        android:fillType="nonZero"
-        android:strokeLineCap="round"/>
-  </group>
-</vector>
diff --git a/app/src/main/res/drawable/launcher_foreground.xml b/app/src/main/res/drawable/launcher_foreground.xml
new file mode 100644 (file)
index 0000000..af159e2
--- /dev/null
@@ -0,0 +1,79 @@
+<!--
+  ~ Copyright © 2021 Damyan Ivanov.
+  ~ This file is part of MoLe.
+  ~ MoLe is free software: you can distribute it and/or modify it
+  ~ under the term of the GNU General Public License as published by
+  ~ the Free Software Foundation, either version 3 of the License, or
+  ~ (at your opinion), any later version.
+  ~
+  ~ MoLe is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  ~ GNU General Public License terms for details.
+  ~
+  ~ You should have received a copy of the GNU General Public License
+  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+  -->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportWidth="108"
+    android:viewportHeight="108"
+    >
+    <group
+        android:scaleX="0.73"
+        android:scaleY="0.73"
+        android:translateX="14.58"
+        android:translateY="14.58"
+        >
+        <path
+            android:pathData="m42.06,32.596c-1.331,1.331 -1.33,3.49 0.002,4.82 1.331,1.33 3.488,1.329 4.818,-0.002 3.962,-3.962 10.28,-3.962 14.242,0 1.33,1.331 3.487,1.332 4.818,0.002 1.332,-1.33 1.333,-3.488 0.002,-4.82 -7.523,-6.703 -17.464,-6.433 -23.881,0z"
+            android:strokeLineJoin="round"
+            android:strokeWidth="7.1924"
+            android:fillColor="#ffffff"
+            android:strokeColor="#00000000"
+            android:fillType="nonZero"
+            android:strokeLineCap="round"/>
+        <path
+            android:pathData="m32.531,23.62c-1.33,1.33 -1.33,3.487 0,4.818 1.33,1.33 3.487,1.33 4.818,0 9.225,-9.227 24.078,-9.227 33.304,0 1.33,1.33 3.487,1.33 4.818,0 1.33,-1.33 1.33,-3.487 0,-4.818 -12.952,-12.351 -31.975,-11.302 -42.939,0z"
+            android:strokeLineJoin="round"
+            android:strokeWidth="7.1924"
+            android:fillColor="#ffffff"
+            android:strokeColor="#00000000"
+            android:fillType="nonZero"
+            android:strokeLineCap="round"/>
+        <path
+            android:pathData="m17.52,41.916c-3.229,0.666 -3.355,3.178 -3.355,3.827L14.165,89.41c0,2.088 1.746,3.837 3.835,3.837 13.048,-2.199 19.956,-2.121 36,0.007C68.79,91.212 79.587,90.998 90,93.247c2.089,0 3.835,-1.749 3.835,-3.837L93.835,45.743c0,-2.094 -1.814,-3.538 -3.636,-3.827 -9.491,-1.502 -13.338,-2.216 -27.678,-0.945 0.484,1.475 0.72,2.785 0.442,4.184 10.721,-1.146 19.131,-0.668 26.469,0.915l-0.045,42.916C77.902,86.92 70.894,86.96 56.213,88.799L56.213,52.774c-1.422,0.348 -3.104,0.332 -4.426,0L51.787,88.799C40.094,86.929 28.873,86.93 18.591,88.987L18.591,46.07c11.035,-1.397 15.892,-2.046 26.444,-0.915 -0.18,-1.441 0.022,-2.978 0.451,-4.184 -9.086,-1.124 -17.014,-0.646 -27.966,0.945z"
+            android:strokeLineJoin="round"
+            android:strokeWidth="4.42609"
+            android:fillColor="#ffffff"
+            android:strokeColor="#00000000"
+            android:fillType="nonZero"
+            android:strokeLineCap="round"/>
+        <path
+            android:pathData="M32.773,55.429L32.773,65.965L22.238,65.965v5.137h10.534v10.534h5.137L37.91,71.102L48.446,71.102L48.446,65.965L37.91,65.965L37.91,55.429Z"
+            android:strokeLineJoin="miter"
+            android:strokeWidth="5.42196"
+            android:fillColor="#ffffff"
+            android:strokeColor="#00000000"
+            android:fillType="evenOdd"
+            android:strokeLineCap="butt"/>
+        <path
+            android:pathData="M59.352,65.965L59.352,71.102L85.559,71.102v-5.137z"
+            android:strokeLineJoin="miter"
+            android:strokeWidth="5.42196"
+            android:fillColor="#ffffff"
+            android:strokeColor="#00000000"
+            android:fillType="evenOdd"
+            android:strokeLineCap="butt"/>
+        <path
+            android:pathData="m54,40.578c1.764,0 3.407,1.645 3.407,3.407 0,1.762 -1.643,3.403 -3.407,3.403 -1.764,0 -3.407,-1.641 -3.407,-3.403 0,-1.762 1.643,-3.407 3.407,-3.407z"
+            android:strokeLineJoin="round"
+            android:strokeWidth="11.5078"
+            android:fillColor="#ffffff"
+            android:strokeColor="#00000000"
+            android:fillType="nonZero"
+            android:strokeLineCap="round"/>
+    </group>
+</vector>
diff --git a/app/src/main/res/layout/account_autocomplete_row.xml b/app/src/main/res/layout/account_autocomplete_row.xml
new file mode 100644 (file)
index 0000000..f30c706
--- /dev/null
@@ -0,0 +1,56 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Copyright © 2021 Damyan Ivanov.
+  ~ This file is part of MoLe.
+  ~ MoLe is free software: you can distribute it and/or modify it
+  ~ under the term of the GNU General Public License as published by
+  ~ the Free Software Foundation, either version 3 of the License, or
+  ~ (at your opinion), any later version.
+  ~
+  ~ MoLe is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  ~ GNU General Public License terms for details.
+  ~
+  ~ You should have received a copy of the GNU General Public License
+  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+  -->
+
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:minHeight="@dimen/thumb_row_height"
+    android:padding="@dimen/half_text_margin"
+    >
+
+    <TextView
+        android:id="@+id/account_name"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="TextView with a very long account name. a really long account name that needs to one\non two lines"
+        android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
+        />
+    <TextView
+        android:id="@+id/amounts"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginStart="@dimen/text_margin"
+        android:gravity="end"
+        android:text="LongCurrencyName 1 234 567,89"
+        android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
+        android:textColor="?commentColor"
+        app:layout_goneMarginStart="0dp"
+        />
+    <androidx.constraintlayout.helper.widget.Flow
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        app:constraint_referenced_ids="account_name,amounts"
+        app:flow_firstHorizontalBias="0"
+        app:flow_firstHorizontalStyle="spread_inside"
+        app:flow_horizontalBias="1"
+        app:flow_verticalStyle="spread_inside"
+        app:flow_wrapMode="chain"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        />
+</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
diff --git a/app/src/main/res/layout/account_list_row.xml b/app/src/main/res/layout/account_list_row.xml
new file mode 100644 (file)
index 0000000..185faf8
--- /dev/null
@@ -0,0 +1,122 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+
+<!--
+  ~ Copyright © 2021 Damyan Ivanov.
+  ~ This file is part of MoLe.
+  ~ MoLe is free software: you can distribute it and/or modify it
+  ~ under the term of the GNU General Public License as published by
+  ~ the Free Software Foundation, either version 3 of the License, or
+  ~ (at your opinion), any later version.
+  ~
+  ~ MoLe is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  ~ GNU General Public License terms for details.
+  ~
+  ~ You should have received a copy of the GNU General Public License
+  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+  -->
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/account_summary_row"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:animateLayoutChanges="true"
+    android:longClickable="true"
+    android:minHeight="@dimen/default_account_row_height"
+    >
+    <androidx.constraintlayout.widget.ConstraintLayout
+        android:id="@+id/account_name_layout"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        app:layout_constraintHorizontal_chainStyle="spread_inside"
+
+        >
+        <TextView
+            android:id="@+id/account_row_acc_name"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginStart="8dp"
+            android:gravity="center_vertical"
+            android:longClickable="true"
+            android:paddingStart="8dp"
+            android:text="Example AccountName That Is Too Long And Has to Be Wrapped On More Than One Line Words Words Words"
+            android:textAppearance="@android:style/TextAppearance.Material.Medium"
+            app:layout_constrainedWidth="true"
+            app:layout_constraintBottom_toBottomOf="parent"
+            app:layout_constraintEnd_toStartOf="@id/account_expander_container"
+            app:layout_constraintStart_toStartOf="parent"
+            app:layout_constraintTop_toTopOf="parent"
+            tools:ignore="HardcodedText"
+            />
+
+        <androidx.constraintlayout.widget.ConstraintLayout
+            android:id="@+id/account_expander_container"
+            android:layout_width="@dimen/thumb_row_height"
+            android:layout_height="@dimen/default_account_row_height"
+            android:foregroundGravity="center_vertical"
+            android:minHeight="@dimen/default_account_row_height"
+            app:layout_constraintBottom_toBottomOf="@id/account_row_acc_name"
+            app:layout_constraintEnd_toEndOf="parent"
+            app:layout_constraintStart_toEndOf="@id/account_row_acc_name"
+            app:layout_constraintTop_toTopOf="@id/account_row_acc_name"
+            >
+
+            <ImageView
+                android:id="@+id/account_expander"
+                android:layout_width="32dp"
+                android:layout_height="32dp"
+                android:background="@drawable/ic_expand_less_black_24dp"
+                android:backgroundTint="?colorPrimary"
+                android:clickable="true"
+                android:contentDescription="@string/sub_accounts_expand_collapse_trigger_description"
+                android:focusable="true"
+                app:layout_constraintBottom_toBottomOf="parent"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintTop_toTopOf="parent"
+                />
+        </androidx.constraintlayout.widget.ConstraintLayout>
+    </androidx.constraintlayout.widget.ConstraintLayout>
+    <TextView
+        android:id="@+id/account_row_acc_amounts"
+        style="@style/account_summary_amounts"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginStart="12dp"
+        android:gravity="center_vertical"
+        android:text="USD 123,45\n678,90\nIRAUSD -17 000.00"
+        android:textAppearance="@style/TextAppearance.AppCompat.Medium"
+        app:layout_constrainedWidth="true"
+        app:layout_constraintWidth_min="90sp"
+        tools:ignore="HardcodedText"
+        />
+
+    <FrameLayout
+        android:id="@+id/account_row_amounts_expander_container"
+        android:layout_width="0dp"
+        android:layout_height="18sp"
+        android:background="@drawable/fade_down_white"
+        app:layout_constraintBottom_toBottomOf="@id/account_row_acc_amounts"
+        app:layout_constraintEnd_toEndOf="@id/account_row_acc_amounts"
+        app:layout_constraintStart_toStartOf="@id/account_row_acc_amounts"
+        />
+    <androidx.constraintlayout.helper.widget.Flow
+        android:id="@+id/flow_wrapper"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:minHeight="@dimen/default_account_row_height"
+        app:constraint_referenced_ids="account_name_layout,account_row_acc_amounts"
+        app:flow_firstHorizontalBias="0"
+        app:flow_firstHorizontalStyle="spread_inside"
+        app:flow_horizontalBias="1"
+        app:flow_verticalStyle="spread_inside"
+        app:flow_wrapMode="chain"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        android:layout_marginEnd="@dimen/half_text_margin"
+        />
+</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
diff --git a/app/src/main/res/layout/account_list_summary_row.xml b/app/src/main/res/layout/account_list_summary_row.xml
new file mode 100644 (file)
index 0000000..9dccb33
--- /dev/null
@@ -0,0 +1,46 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+
+<!--
+  ~ Copyright © 2021 Damyan Ivanov.
+  ~ This file is part of MoLe.
+  ~ MoLe is free software: you can distribute it and/or modify it
+  ~ under the term of the GNU General Public License as published by
+  ~ the Free Software Foundation, either version 3 of the License, or
+  ~ (at your opinion), any later version.
+  ~
+  ~ MoLe is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  ~ GNU General Public License terms for details.
+  ~
+  ~ You should have received a copy of the GNU General Public License
+  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+  -->
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/account_summary_row"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:animateLayoutChanges="true"
+    android:longClickable="true"
+    android:paddingTop="4dp"
+    >
+
+    <TextView
+        android:id="@+id/last_update_text"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginHorizontal="@dimen/activity_horizontal_margin"
+        android:layout_weight="1"
+        android:gravity="center"
+        android:text="1 123 transactions as of 29.02.2020 13:37"
+        android:textAppearance="@android:style/TextAppearance.Material.Small"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        tools:ignore="HardcodedText"
+        />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
diff --git a/app/src/main/res/layout/account_summary_row.xml b/app/src/main/res/layout/account_summary_row.xml
deleted file mode 100644 (file)
index 4f345e7..0000000
+++ /dev/null
@@ -1,122 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-
-
-<!--
-  ~ Copyright © 2020 Damyan Ivanov.
-  ~ This file is part of MoLe.
-  ~ MoLe is free software: you can distribute it and/or modify it
-  ~ under the term of the GNU General Public License as published by
-  ~ the Free Software Foundation, either version 3 of the License, or
-  ~ (at your opinion), any later version.
-  ~
-  ~ MoLe is distributed in the hope that it will be useful,
-  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
-  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-  ~ GNU General Public License terms for details.
-  ~
-  ~ You should have received a copy of the GNU General Public License
-  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
-  -->
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:id="@+id/account_summary_row"
-    android:layout_width="match_parent"
-    android:layout_height="wrap_content"
-    android:animateLayoutChanges="true"
-    android:longClickable="true"
-    >
-    <androidx.constraintlayout.widget.ConstraintLayout
-        android:id="@+id/account_name_layout"
-        android:layout_width="0dp"
-        android:layout_height="wrap_content"
-        app:layout_constraintEnd_toStartOf="@+id/account_row_acc_amounts"
-        app:layout_constraintHorizontal_chainStyle="spread_inside"
-        app:layout_constraintHorizontal_weight="3"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent"
-        >
-        <TextView
-            android:id="@+id/account_row_acc_name"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:layout_marginStart="8dp"
-            android:gravity="center_vertical"
-            android:longClickable="true"
-            android:minHeight="@dimen/default_account_row_height"
-            android:paddingStart="8dp"
-            android:text="AccountName"
-            android:textAppearance="@android:style/TextAppearance.Material.Medium"
-            app:layout_constrainedWidth="true"
-            app:layout_constraintEnd_toStartOf="@id/account_expander_container"
-            app:layout_constraintHorizontal_bias="0.0"
-            app:layout_constraintHorizontal_chainStyle="packed"
-            app:layout_constraintStart_toStartOf="parent"
-            app:layout_constraintTop_toTopOf="parent"
-            app:layout_constraintWidth_max="wrap"
-            app:layout_constraintWidth_percent=".67"
-            tools:ignore="HardcodedText"
-            />
-
-        <androidx.constraintlayout.widget.ConstraintLayout
-            android:id="@+id/account_expander_container"
-            android:layout_width="@dimen/thumb_row_height"
-            android:layout_height="@dimen/default_account_row_height"
-            android:foregroundGravity="center_vertical"
-            android:minHeight="@dimen/default_account_row_height"
-            app:layout_constraintBottom_toBottomOf="@id/account_row_acc_name"
-            app:layout_constraintEnd_toEndOf="parent"
-            app:layout_constraintStart_toEndOf="@id/account_row_acc_name"
-            app:layout_constraintTop_toTopOf="@id/account_row_acc_name"
-            >
-
-            <ImageView
-                android:id="@+id/account_expander"
-                android:layout_width="32dp"
-                android:layout_height="32dp"
-                android:background="@drawable/ic_expand_less_black_24dp"
-                android:backgroundTint="?colorPrimary"
-                android:clickable="true"
-                android:contentDescription="@string/sub_accounts_expand_collapse_trigger_description"
-                android:focusable="true"
-                app:layout_constraintBottom_toBottomOf="parent"
-                app:layout_constraintEnd_toEndOf="parent"
-                app:layout_constraintStart_toStartOf="parent"
-                app:layout_constraintTop_toTopOf="parent"
-                />
-        </androidx.constraintlayout.widget.ConstraintLayout>
-    </androidx.constraintlayout.widget.ConstraintLayout>
-    <TextView
-        android:id="@+id/account_row_acc_amounts"
-        style="@style/account_summary_amounts"
-        android:layout_width="0dp"
-        android:layout_height="wrap_content"
-        android:layout_marginStart="12dp"
-        android:layout_marginEnd="8dp"
-        android:gravity="center_vertical"
-        android:minHeight="@dimen/default_account_row_height"
-        android:text="USD 123,45\n678,90\nIRAUSD -17 000.00"
-        android:textAppearance="@style/TextAppearance.AppCompat.Medium"
-        app:layout_constrainedWidth="true"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintHorizontal_weight="2"
-        app:layout_constraintStart_toEndOf="@id/account_name_layout"
-        app:layout_constraintTop_toTopOf="parent"
-        app:layout_constraintWidth_min="90sp"
-        tools:ignore="HardcodedText"
-        />
-
-    <FrameLayout
-        android:id="@+id/account_row_amounts_expander_container"
-        android:layout_width="0dp"
-        android:layout_height="18sp"
-        android:background="@drawable/fade_down_white"
-        app:layout_constraintBottom_toBottomOf="@id/account_row_acc_amounts"
-        app:layout_constraintEnd_toEndOf="@id/account_row_acc_amounts"
-        app:layout_constraintStart_toStartOf="@id/account_row_acc_amounts"
-        >
-
-    </FrameLayout>
-    <include layout="@layout/last_update_layout" />
-</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
index 1a64fbcb4076bde291dd8c48727c1c99ccbb4226..bd74773af3cbd3f81aad3aaac663e7e9f0980024 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?><!--
-  ~ Copyright © 2019 Damyan Ivanov.
+  ~ Copyright © 2021 Damyan Ivanov.
   ~ This file is part of MoLe.
   ~ MoLe is free software: you can distribute it and/or modify it
   ~ under the term of the GNU General Public License as published by
   ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
   -->
 <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
     android:layout_width="match_parent"
-    android:layout_height="match_parent">
+    android:layout_height="match_parent"
+    tools:context=".ui.activity.MainActivity"
+    >
 
-    <include
-        layout="@layout/no_profiles"
-        android:visibility="gone" />
+    <ScrollView
+        android:id="@+id/no_profiles_layout"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:background="?table_row_dark_bg"
+        android:visibility="visible"
+        >
+        <androidx.constraintlayout.widget.ConstraintLayout xmlns:app="http://schemas.android.com/apk/res-auto"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            >
 
-    <include
-        layout="@layout/main_app_layout"
-        android:visibility="gone" />
+            <FrameLayout
+                android:id="@+id/welcome_header"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintTop_toTopOf="parent"
+                >
+
+                <include layout="@layout/nav_header_layout" />
+            </FrameLayout>
+
+            <androidx.constraintlayout.widget.ConstraintLayout
+                android:layout_width="0dp"
+                android:layout_height="wrap_content"
+                android:padding="@dimen/activity_horizontal_margin"
+                app:layout_constraintBottom_toBottomOf="parent"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintTop_toBottomOf="@id/welcome_header"
+                >
+
+                <TextView
+                    android:id="@+id/textView"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:layout_marginVertical="48dp"
+                    android:text="@string/text_welcome"
+                    android:textColor="?textColor"
+                    android:textSize="48sp"
+                    app:layout_constraintBottom_toTopOf="@id/textView3"
+                    app:layout_constraintEnd_toEndOf="parent"
+                    app:layout_constraintStart_toStartOf="parent"
+                    app:layout_constraintTop_toTopOf="parent"
+                    />
+
+                <TextView
+                    android:id="@+id/textView3"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:layout_marginVertical="24dp"
+                    android:layout_marginStart="8dp"
+                    android:layout_marginEnd="8dp"
+                    android:text="@string/text_welcome_profile_needed"
+                    android:textColor="?textColor"
+                    android:textSize="20sp"
+                    app:layout_constraintBottom_toTopOf="@id/btn_no_profiles_add"
+                    app:layout_constraintEnd_toEndOf="parent"
+                    app:layout_constraintStart_toStartOf="parent"
+                    app:layout_constraintTop_toBottomOf="@+id/textView"
+                    />
+
+                <Button
+                    android:id="@+id/btn_no_profiles_add"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:layout_marginVertical="24dp"
+                    android:layout_marginStart="8dp"
+                    android:layout_marginEnd="8dp"
+                    android:backgroundTint="?colorSecondary"
+                    android:drawablePadding="16dp"
+                    android:text="@string/create_profile_label"
+                    android:textColor="@color/design_default_color_on_primary"
+                    app:layout_constraintBottom_toTopOf="@id/restore_hint"
+                    app:layout_constraintEnd_toEndOf="parent"
+                    app:layout_constraintStart_toStartOf="parent"
+                    app:layout_constraintTop_toBottomOf="@+id/textView3"
+                    />
+                <TextView
+                    android:id="@+id/restore_hint"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:layout_marginHorizontal="8dp"
+                    android:layout_marginVertical="24dp"
+                    android:text="@string/no_profile_restore_hint"
+                    android:textColor="?textColor"
+                    android:textSize="20sp"
+                    app:layout_constraintBottom_toTopOf="@id/btn_restore"
+                    app:layout_constraintEnd_toEndOf="parent"
+                    app:layout_constraintStart_toStartOf="parent"
+                    app:layout_constraintTop_toBottomOf="@id/btn_no_profiles_add"
+                    />
+
+                <Button
+                    android:id="@+id/btn_restore"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:layout_marginHorizontal="8dp"
+                    android:layout_marginVertical="24dp"
+                    android:drawableStart="@drawable/ic_baseline_restore_24"
+                    android:drawablePadding="@dimen/text_margin"
+                    android:text="@string/restore_button_label"
+                    app:layout_constraintBottom_toBottomOf="parent"
+                    app:layout_constraintEnd_toEndOf="parent"
+                    app:layout_constraintStart_toStartOf="parent"
+                    app:layout_constraintTop_toBottomOf="@id/restore_hint"
+                    />
+            </androidx.constraintlayout.widget.ConstraintLayout>
+        </androidx.constraintlayout.widget.ConstraintLayout>
+    </ScrollView>
+    <androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:app="http://schemas.android.com/apk/res-auto"
+        android:id="@+id/main_app_layout"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:background="?android:attr/colorBackground"
+        android:orientation="vertical"
+        android:visibility="gone"
+        >
+
+        <com.google.android.material.floatingactionbutton.FloatingActionButton
+            android:id="@+id/btn_add_transaction"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_gravity="bottom|end"
+            android:layout_margin="@dimen/fab_margin"
+            android:contentDescription="@string/new_transaction_fab_description"
+            app:backgroundTint="?colorSecondary"
+            app:layout_constraintBottom_toBottomOf="parent"
+            app:layout_constraintEnd_toEndOf="parent"
+            app:maxImageSize="36dp"
+            app:srcCompat="@drawable/ic_add_white_24dp"
+            />
+
+        <androidx.drawerlayout.widget.DrawerLayout
+            android:id="@+id/drawer_layout"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            tools:openDrawer="start"
+            >
+
+            <androidx.constraintlayout.widget.ConstraintLayout
+                android:id="@+id/pager_layout"
+                android:layout_width="match_parent"
+                android:layout_height="match_parent"
+                >
+
+                <androidx.appcompat.widget.Toolbar
+                    android:id="@+id/toolbar"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:background="?colorPrimary"
+                    android:theme="@style/AppTheme.AppBarOverlay"
+                    app:layout_constraintEnd_toEndOf="parent"
+                    app:layout_constraintStart_toStartOf="parent"
+                    app:layout_constraintTop_toTopOf="parent"
+                    app:popupTheme="@style/AppTheme.PopupOverlay"
+                    app:subtitleTextAppearance="@style/TextAppearance.AppCompat.Widget.ActionBar.Subtitle"
+                    app:titleTextAppearance="@style/TextAppearance.AppCompat.Widget.ActionBar.Title"
+                    />
+
+
+                <LinearLayout
+                    android:id="@+id/transaction_progress_layout"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:gravity="center_vertical"
+                    android:orientation="horizontal"
+                    android:visibility="gone"
+                    app:layout_constraintEnd_toEndOf="parent"
+                    app:layout_constraintStart_toStartOf="parent"
+                    app:layout_constraintTop_toBottomOf="@id/toolbar"
+                    >
+
+                    <ProgressBar
+                        android:id="@+id/transaction_list_progress_bar"
+                        style="?android:attr/progressBarStyleHorizontal"
+                        android:layout_width="0dp"
+                        android:layout_height="wrap_content"
+                        android:layout_marginTop="-8dp"
+                        android:layout_marginBottom="-7dp"
+                        android:layout_weight="1"
+                        android:indeterminate="true"
+                        android:min="0"
+                        android:padding="0dp"
+                        android:progressTint="?colorPrimary"
+                        app:layout_constraintEnd_toEndOf="parent"
+                        app:layout_constraintStart_toStartOf="parent"
+                        />
+
+                    <TextView
+                        android:id="@+id/transaction_list_cancel_download"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:background="@drawable/ic_clear_accent_24dp"
+                        android:clickable="true"
+                        android:focusable="true"
+                        />
+                </LinearLayout>
+
+                <androidx.viewpager2.widget.ViewPager2
+                    android:id="@+id/main_pager"
+                    android:layout_width="match_parent"
+                    android:layout_height="0dp"
+                    app:layout_constraintBottom_toBottomOf="parent"
+                    app:layout_constraintEnd_toEndOf="parent"
+                    app:layout_constraintStart_toStartOf="parent"
+                    app:layout_constraintTop_toBottomOf="@id/transaction_progress_layout"
+                    >
+
+                </androidx.viewpager2.widget.ViewPager2>
+
+                <View
+                    android:layout_width="0dp"
+                    android:layout_height="?attr/main_header_shadow_height"
+                    android:background="@drawable/drop_shadow"
+                    app:layout_constraintEnd_toEndOf="parent"
+                    app:layout_constraintStart_toStartOf="parent"
+                    app:layout_constraintTop_toBottomOf="@id/transaction_progress_layout"
+                    />
+
+
+            </androidx.constraintlayout.widget.ConstraintLayout>
+
+            <com.google.android.material.navigation.NavigationView xmlns:app="http://schemas.android.com/apk/res-auto"
+                android:id="@+id/nav_view"
+                android:layout_width="wrap_content"
+                android:layout_height="match_parent"
+                android:layout_gravity="start"
+                android:fitsSystemWindows="true"
+                >
+
+
+                <androidx.constraintlayout.widget.ConstraintLayout
+                    android:layout_width="match_parent"
+                    android:layout_height="match_parent"
+                    android:layout_marginBottom="0dp"
+                    android:animateLayoutChanges="true"
+                    android:orientation="vertical"
+                    >
+
+                    <LinearLayout
+                        android:id="@+id/nav_fixed_items"
+                        android:layout_width="match_parent"
+                        android:layout_height="wrap_content"
+                        android:divider="@drawable/list_divider"
+                        android:elevation="2dp"
+                        android:orientation="vertical"
+                        android:showDividers="beginning"
+                        android:visibility="visible"
+                        app:layout_constraintBottom_toBottomOf="parent"
+                        >
+
+                        <TextView
+                            android:id="@+id/textView2"
+                            style="@style/nav_button"
+                            android:layout_weight="1"
+                            android:text="@string/action_settings"
+                            android:visibility="gone"
+                            app:drawableStartCompat="@drawable/ic_settings_black_24dp"
+                            />
+                        <TextView
+                            android:id="@+id/nav_backup_restore"
+                            style="@style/nav_button"
+                            android:layout_weight="1"
+                            android:text="@string/action_import_export"
+                            app:drawableStartCompat="@drawable/ic_baseline_backup_24"
+                            />
+
+                    </LinearLayout>
+
+                    <ScrollView
+                        android:layout_width="0dp"
+                        android:layout_height="0dp"
+                        app:layout_constraintBottom_toTopOf="@+id/nav_fixed_items"
+                        app:layout_constraintEnd_toEndOf="parent"
+                        app:layout_constraintLeft_toLeftOf="parent"
+                        app:layout_constraintStart_toStartOf="parent"
+                        app:layout_constraintTop_toTopOf="parent"
+                        >
+
+                        <LinearLayout
+                            android:id="@+id/nav_upper"
+                            android:layout_width="match_parent"
+                            android:layout_height="wrap_content"
+                            android:animateLayoutChanges="true"
+                            android:orientation="vertical"
+                            android:showDividers="beginning"
+                            app:layout_constraintBottom_toTopOf="@+id/nav_fixed_items"
+                            app:layout_constraintTop_toBottomOf="@+id/nav_header"
+                            >
+
+                            <include layout="@layout/nav_header_layout" />
+
+                            <LinearLayout
+                                android:id="@+id/nav_actions"
+                                android:layout_width="match_parent"
+                                android:layout_height="match_parent"
+                                android:orientation="vertical"
+                                >
+
+                                <TextView
+                                    android:id="@+id/nav_account_summary"
+                                    style="@style/nav_button"
+                                    android:text="@string/account_summary_title"
+                                    app:drawableStartCompat="@drawable/ic_home_black_24dp"
+                                    />
+
+                                <TextView
+                                    android:id="@+id/nav_latest_transactions"
+                                    style="@style/nav_button"
+                                    android:text="@string/nav_transactions_title"
+                                    app:drawableStartCompat="@drawable/ic_event_note_black_24dp"
+                                    />
+
+                                <TextView
+                                    android:id="@+id/textView5"
+                                    style="@style/nav_button"
+                                    android:text="@string/nav_reports_title"
+                                    android:visibility="gone"
+                                    app:drawableStartCompat="@drawable/ic_assignment_black_24dp"
+                                    />
+
+                                <androidx.constraintlayout.widget.ConstraintLayout
+                                    android:id="@+id/nav_profile_list_head_layout"
+                                    android:layout_width="match_parent"
+                                    android:layout_height="@dimen/thumb_row_height"
+                                    >
+
+                                    <ImageView
+                                        android:id="@+id/nav_new_profile_button"
+                                        android:layout_width="wrap_content"
+                                        android:layout_height="wrap_content"
+                                        android:layout_gravity="center"
+                                        android:contentDescription="@string/icon"
+                                        android:paddingStart="8dp"
+                                        android:paddingEnd="8dp"
+                                        android:visibility="gone"
+                                        app:layout_constraintBottom_toBottomOf="parent"
+                                        app:layout_constraintEnd_toStartOf="@id/nav_profile_list_head_buttons"
+                                        app:layout_constraintStart_toEndOf="@id/nav_profiles_label"
+                                        app:layout_constraintTop_toTopOf="parent"
+                                        app:srcCompat="@drawable/ic_add_circle_white_24dp"
+                                        />
+
+                                    <LinearLayout
+                                        android:id="@+id/nav_profile_list_head_buttons"
+                                        android:layout_width="wrap_content"
+                                        android:layout_height="0dp"
+                                        android:gravity="center_vertical"
+                                        android:orientation="horizontal"
+                                        android:paddingStart="16dp"
+                                        android:paddingEnd="16dp"
+                                        app:layout_constraintBottom_toBottomOf="parent"
+                                        app:layout_constraintEnd_toEndOf="parent"
+                                        app:layout_constraintTop_toTopOf="parent"
+                                        >
+
+                                        <ImageView
+                                            android:id="@+id/nav_profiles_cancel_edit"
+                                            android:layout_width="wrap_content"
+                                            android:layout_height="wrap_content"
+                                            android:background="@drawable/ic_clear_accent_24dp"
+                                            android:contentDescription="@string/icon"
+                                            android:gravity="end|center_vertical"
+                                            android:paddingStart="8dp"
+                                            android:paddingEnd="8dp"
+                                            android:visibility="gone"
+                                            app:layout_constraintBottom_toBottomOf="parent"
+                                            app:layout_constraintEnd_toEndOf="parent"
+                                            app:layout_constraintTop_toTopOf="parent"
+                                            />
+
+                                        <ImageView
+                                            android:id="@+id/nav_profiles_start_edit"
+                                            android:layout_width="wrap_content"
+                                            android:layout_height="wrap_content"
+                                            android:background="@drawable/ic_settings_black_24dp"
+                                            android:contentDescription="@string/icon"
+                                            android:gravity="end|center_vertical"
+                                            android:paddingStart="8dp"
+                                            android:paddingEnd="8dp"
+                                            app:layout_constraintBottom_toBottomOf="parent"
+                                            app:layout_constraintEnd_toEndOf="parent"
+                                            app:layout_constraintTop_toTopOf="parent"
+                                            />
+
+                                    </LinearLayout>
+
+                                    <TextView
+                                        android:id="@+id/nav_profiles_label"
+                                        style="@style/nav_button"
+                                        android:layout_width="wrap_content"
+                                        android:layout_height="0dp"
+                                        android:gravity="start|center_vertical"
+                                        android:text="@string/profiles"
+                                        app:layout_constraintBottom_toBottomOf="parent"
+                                        app:layout_constraintStart_toStartOf="parent"
+                                        app:layout_constraintTop_toTopOf="parent"
+                                        />
+
+                                </androidx.constraintlayout.widget.ConstraintLayout>
+                                <LinearLayout
+                                    android:id="@+id/nav_profile_list_container"
+                                    android:layout_width="match_parent"
+                                    android:layout_height="wrap_content"
+                                    android:animateLayoutChanges="true"
+                                    android:nestedScrollingEnabled="false"
+                                    android:orientation="vertical"
+                                    >
+
+                                    <androidx.recyclerview.widget.RecyclerView
+                                        android:id="@+id/nav_profile_list"
+                                        android:layout_width="match_parent"
+                                        android:layout_height="wrap_content"
+                                        android:isScrollContainer="false"
+                                        android:nestedScrollingEnabled="false"
+                                        android:orientation="vertical"
+                                        >
+
+                                    </androidx.recyclerview.widget.RecyclerView>
+
+                                </LinearLayout>
+
+                            </LinearLayout>
+                            <TextView
+                                android:id="@+id/nav_patterns"
+                                style="@style/nav_button"
+                                android:text="@string/nav_templates"
+                                app:drawableStartCompat="@drawable/ic_baseline_auto_graph_24"
+                                />
+
+                        </LinearLayout>
+                    </ScrollView>
+
+                </androidx.constraintlayout.widget.ConstraintLayout>
+
+            </com.google.android.material.navigation.NavigationView>
+        </androidx.drawerlayout.widget.DrawerLayout>
+    </androidx.coordinatorlayout.widget.CoordinatorLayout>
 </FrameLayout>
\ No newline at end of file
index 706794f9df81fcda0680c2eb89871b00d2eac007..55273c7414e947136fe40be282f45fe0444a045c 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?><!--
-  ~ Copyright © 2020 Damyan Ivanov.
+  ~ Copyright © 2021 Damyan Ivanov.
   ~ This file is part of MoLe.
   ~ MoLe is free software: you can distribute it and/or modify it
   ~ under the term of the GNU General Public License as published by
@@ -20,7 +20,8 @@
     xmlns:tools="http://schemas.android.com/tools"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
-    tools:context=".ui.activity.NewTransactionActivity"
+    tools:context=".ui.new_transaction.NewTransactionActivity"
+    android:fitsSystemWindows="false"
     >
 
     <androidx.constraintlayout.widget.ConstraintLayout
             <androidx.appcompat.widget.Toolbar
                 android:id="@+id/toolbar"
                 android:layout_width="match_parent"
-                android:layout_height="@dimen/toolbar_height"
+                android:layout_height="wrap_content"
+                android:minHeight="?attr/actionBarSize"
                 android:background="?attr/colorPrimary"
                 app:popupTheme="@style/AppTheme.PopupOverlay"
+                app:subtitleTextAppearance="@style/TextAppearance.AppCompat.Widget.ActionBar.Subtitle"
+                app:titleTextAppearance="@style/TextAppearance.AppCompat.Widget.ActionBar.Title"
                 />
 
         </com.google.android.material.appbar.AppBarLayout>
             />
 
     </androidx.constraintlayout.widget.ConstraintLayout>
-
+    <com.google.android.material.floatingactionbutton.FloatingActionButton
+        android:id="@+id/fabAdd"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_gravity="bottom|end"
+        android:layout_marginEnd="@dimen/fab_margin"
+        android:layout_marginBottom="@dimen/fab_margin"
+        android:contentDescription="@string/add_button_description"
+        android:padding="@dimen/fab_margin"
+        android:tint="?android:attr/colorBackground"
+        android:visibility="visible"
+        app:backgroundTint="?colorSecondary"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:srcCompat="@drawable/ic_save_white_24dp"
+        />
 </androidx.coordinatorlayout.widget.CoordinatorLayout>
\ No newline at end of file
index 87c7554aaa62f38dae146045438952cf6cc88d6f..0a379e088744de8ff31346f44d993973cf7a80af 100644 (file)
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
 <!--
-  ~ Copyright © 2020 Damyan Ivanov.
+  ~ Copyright © 2021 Damyan Ivanov.
   ~ This file is part of MoLe.
   ~ MoLe is free software: you can distribute it and/or modify it
   ~ under the term of the GNU General Public License as published by
     xmlns:tools="http://schemas.android.com/tools"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
-    tools:context=".ui.activity.ProfileDetailActivity">
+    tools:context=".ui.profiles.ProfileDetailActivity"
+    android:fitsSystemWindows="false"
+    >
 
     <com.google.android.material.appbar.AppBarLayout
         android:id="@+id/app_bar"
         android:layout_width="match_parent"
         android:layout_height="@dimen/app_bar_height"
         android:fitsSystemWindows="true"
-        android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar">
+        android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
+        >
 
         <com.google.android.material.appbar.CollapsingToolbarLayout
             android:id="@+id/toolbar_layout"
@@ -58,7 +61,7 @@
         app:layout_behavior="@string/appbar_scrolling_view_behavior" />
 
     <com.google.android.material.floatingactionbutton.FloatingActionButton
-        android:id="@+id/fab"
+        android:id="@+id/fabAdd"
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
         android:layout_gravity="center_vertical|start"
diff --git a/app/src/main/res/layout/activity_templates.xml b/app/src/main/res/layout/activity_templates.xml
new file mode 100644 (file)
index 0000000..cd2b409
--- /dev/null
@@ -0,0 +1,68 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Copyright © 2021 Damyan Ivanov.
+  ~ This file is part of MoLe.
+  ~ MoLe is free software: you can distribute it and/or modify it
+  ~ under the term of the GNU General Public License as published by
+  ~ the Free Software Foundation, either version 3 of the License, or
+  ~ (at your opinion), any later version.
+  ~
+  ~ MoLe is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  ~ GNU General Public License terms for details.
+  ~
+  ~ You should have received a copy of the GNU General Public License
+  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+  -->
+
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context=".ui.templates.TemplatesActivity"
+    >
+
+    <com.google.android.material.appbar.AppBarLayout
+        android:id="@+id/appbar"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:fitsSystemWindows="true"
+        android:theme="@style/AppTheme.AppBarOverlay"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        >
+        <androidx.appcompat.widget.Toolbar
+            android:id="@+id/toolbar"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:background="?attr/colorPrimary"
+            app:layout_collapseMode="pin"
+            app:popupTheme="@style/AppTheme.PopupOverlay"
+            />
+    </com.google.android.material.appbar.AppBarLayout>
+
+    <androidx.fragment.app.FragmentContainerView
+        android:id="@+id/fragment_container"
+        android:name="androidx.navigation.fragment.NavHostFragment"
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        app:defaultNavHost="true"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/appbar"
+        app:navGraph="@navigation/template_list_navigation"
+        />
+
+    <com.google.android.material.floatingactionbutton.FloatingActionButton
+        android:id="@+id/fab"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_margin="@dimen/fab_margin"
+        android:contentDescription="@string/add_button_description"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:srcCompat="@drawable/ic_add_white_24dp"
+        />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/app/src/main/res/layout/fragment_backups.xml b/app/src/main/res/layout/fragment_backups.xml
new file mode 100644 (file)
index 0000000..89344af
--- /dev/null
@@ -0,0 +1,130 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Copyright © 2021 Damyan Ivanov.
+  ~ This file is part of MoLe.
+  ~ MoLe is free software: you can distribute it and/or modify it
+  ~ under the term of the GNU General Public License as published by
+  ~ the Free Software Foundation, either version 3 of the License, or
+  ~ (at your opinion), any later version.
+  ~
+  ~ MoLe is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  ~ GNU General Public License terms for details.
+  ~
+  ~ You should have received a copy of the GNU General Public License
+  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+  -->
+
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context=".BackupsActivity"
+    >
+    <com.google.android.material.appbar.AppBarLayout
+        android:id="@+id/appbar"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:fitsSystemWindows="true"
+        android:theme="@style/AppTheme.AppBarOverlay"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        >
+        <androidx.appcompat.widget.Toolbar
+            android:id="@+id/toolbar"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:background="?attr/colorPrimary"
+            app:layout_collapseMode="pin"
+            app:popupTheme="@style/AppTheme.PopupOverlay"
+            />
+    </com.google.android.material.appbar.AppBarLayout>
+
+    <ScrollView
+        android:layout_width="0dp"
+        android:layout_height="0dp"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/appbar"
+        >
+        <androidx.constraintlayout.widget.ConstraintLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginHorizontal="@dimen/activity_horizontal_margin"
+            >
+            <TextView
+                android:id="@+id/backup_header"
+                style="@style/TextAppearance.MaterialComponents.Headline4"
+                android:layout_width="0dp"
+                android:layout_height="wrap_content"
+                android:text="@string/backup_header"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintTop_toTopOf="parent"
+                />
+            <TextView
+                android:id="@+id/backup_explanation_text"
+                style="@style/TextAppearance.MaterialComponents.Body1"
+                android:layout_width="0dp"
+                android:layout_height="wrap_content"
+                android:text="@string/backup_explanation"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintTop_toBottomOf="@id/backup_header"
+                />
+            <TextView
+                android:id="@+id/backup_button"
+                style="@style/TextAppearance.MaterialComponents.Button"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginVertical="@dimen/text_margin"
+                android:drawablePadding="@dimen/text_margin"
+                android:gravity="center_vertical"
+                android:text="@string/backup_button_label"
+                app:drawableStartCompat="@drawable/ic_baseline_backup_24"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintHorizontal_bias="1"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintTop_toBottomOf="@id/backup_explanation_text"
+                />
+            <TextView
+                android:id="@+id/restore_header"
+                style="@style/TextAppearance.MaterialComponents.Headline4"
+                android:layout_width="0dp"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="@dimen/text_margin"
+                android:text="@string/restore_header"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintTop_toBottomOf="@id/backup_button"
+                />
+            <TextView
+                android:id="@+id/restore_explanation_text"
+                style="@style/TextAppearance.MaterialComponents.Body1"
+                android:layout_width="0dp"
+                android:layout_height="wrap_content"
+                android:text="@string/restore_explanation"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintTop_toBottomOf="@id/restore_header"
+                />
+            <TextView
+                android:id="@+id/restore_button"
+                style="@style/TextAppearance.MaterialComponents.Button"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginVertical="@dimen/text_margin"
+                android:drawablePadding="@dimen/text_margin"
+                android:gravity="center_vertical"
+                android:text="@string/restore_button_label"
+                app:drawableStartCompat="@drawable/ic_baseline_restore_24"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintHorizontal_bias="1"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintTop_toBottomOf="@id/restore_explanation_text"
+                />
+        </androidx.constraintlayout.widget.ConstraintLayout>
+    </ScrollView>
+</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
index 7fd49aa2baec810b548bf170436d991ba2cfaeb5..a44f0ec1c4d730902bcc7175a09a807269445e06 100644 (file)
     xmlns:tools="http://schemas.android.com/tools"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
-    android:minWidth="60dp"
     android:animateLayoutChanges="true"
+    android:minWidth="60dp"
+    android:padding="@dimen/text_margin"
     app:layout_constraintWidth_min="60dp"
-    android:padding="@dimen/text_margin">
+    >
 
     <com.google.android.material.textview.MaterialTextView
         android:id="@+id/label"
         android:layout_marginBottom="@dimen/text_margin"
         android:text="@string/choose_currency_label"
         android:textSize="18sp"
+        app:layout_constraintBottom_toTopOf="@id/list"
         app:layout_constraintEnd_toEndOf="parent"
         app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent" />
+        app:layout_constraintTop_toTopOf="parent"
+        />
 
     <androidx.recyclerview.widget.RecyclerView
         android:id="@+id/list"
         android:name="net.ktnx.mobileledger.ui.CurrencySelectorFragment"
         android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
+        android:layout_height="200dp"
+        android:minHeight="100dp"
         android:layout_marginLeft="@dimen/activity_horizontal_margin"
         android:layout_marginRight="@dimen/activity_horizontal_margin"
         app:layoutManager="LinearLayoutManager"
@@ -49,7 +53,8 @@
         app:layout_constraintStart_toStartOf="parent"
         app:layout_constraintTop_toBottomOf="@id/label"
         tools:context="net.ktnx.mobileledger.ui.CurrencySelectorFragment"
-        tools:listitem="@layout/fragment_currency_selector">
+        tools:listitem="@layout/fragment_currency_selector"
+        >
 
     </androidx.recyclerview.widget.RecyclerView>
 
         android:layout_height="wrap_content"
         android:layout_marginLeft="@dimen/activity_horizontal_margin"
         android:layout_marginRight="@dimen/activity_horizontal_margin"
-        app:layout_constraintTop_toBottomOf="@id/list"
         app:layout_constraintBottom_toTopOf="@id/params_panel"
         app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent">
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/list"
+        >
 
         <TextView
             android:id="@+id/btn_add_new"
             style="@style/Widget.MaterialComponents.Button.TextButton"
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
-            android:text="@string/add_button"
             android:layout_margin="@dimen/text_margin"
+            android:text="@string/add_button"
             app:layout_constraintBottom_toBottomOf="parent"
             app:layout_constraintEnd_toEndOf="parent"
             app:layout_constraintStart_toEndOf="@id/btn_no_currency"
-            app:layout_constraintTop_toTopOf="parent" />
+            app:layout_constraintTop_toTopOf="parent"
+            />
         <TextView
+            android:id="@+id/btn_no_currency"
+            style="@style/Widget.MaterialComponents.Button.TextButton"
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
-            android:id="@+id/btn_no_currency"
+            android:layout_margin="@dimen/text_margin"
             android:text="@string/btn_no_currency"
-            style="@style/Widget.MaterialComponents.Button.TextButton"
             app:layout_constraintBottom_toBottomOf="parent"
-            app:layout_constraintTop_toTopOf="parent"
-            app:layout_constraintStart_toStartOf="parent"
             app:layout_constraintEnd_toStartOf="@id/btn_add_new"
-            android:layout_margin="@dimen/text_margin"/>
+            app:layout_constraintStart_toStartOf="parent"
+            app:layout_constraintTop_toTopOf="parent"
+            />
 
         <EditText
             android:id="@+id/new_currency_name"
             android:visibility="invisible"
             app:layout_constraintEnd_toEndOf="parent"
             app:layout_constraintStart_toStartOf="parent"
-            app:layout_constraintTop_toTopOf="parent" />
+            app:layout_constraintTop_toTopOf="parent"
+            android:autofillHints="currency"
+            />
 
         <TextView
             android:id="@+id/btn_add_currency"
             android:text="@string/add_button"
             app:layout_constraintBottom_toBottomOf="parent"
             app:layout_constraintEnd_toEndOf="parent"
-            app:layout_constraintTop_toBottomOf="@id/new_currency_name" />
+            app:layout_constraintTop_toBottomOf="@id/new_currency_name"
+            />
     </androidx.constraintlayout.widget.ConstraintLayout>
 
     <androidx.constraintlayout.widget.ConstraintLayout
+        android:id="@+id/params_panel"
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
-        android:id="@+id/params_panel"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintEnd_toEndOf="parent"
         app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintTop_toBottomOf="@id/new_currency_panel">
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/new_currency_panel"
+        >
 
         <RadioGroup
             android:id="@+id/position_radio_group"
             android:layout_width="match_parent"
             android:layout_height="wrap_content"
-            app:layout_constraintTop_toTopOf="parent"
-            app:layout_constraintStart_toStartOf="parent"
-            app:layout_constraintEnd_toEndOf="parent"
+            android:layout_marginBottom="@dimen/text_margin"
             android:orientation="horizontal"
             app:layout_constraintBottom_toTopOf="@id/currency_gap"
-            android:layout_marginBottom="@dimen/text_margin"
+            app:layout_constraintEnd_toEndOf="parent"
+            app:layout_constraintStart_toStartOf="parent"
+            app:layout_constraintTop_toTopOf="parent"
             >
 
             <RadioButton
                 android:layout_width="wrap_content"
                 android:layout_height="wrap_content"
                 android:layout_weight="1"
-                android:text="@string/currency_position_left" />
+                android:text="@string/currency_position_left"
+                />
 
             <RadioButton
                 android:id="@+id/currency_position_right"
                 android:layout_width="wrap_content"
                 android:layout_height="wrap_content"
                 android:layout_weight="1"
-                android:text="@string/currency_position_right" />
+                android:text="@string/currency_position_right"
+                />
         </RadioGroup>
 
-        <Switch
+        <com.google.android.material.switchmaterial.SwitchMaterial
             android:id="@+id/currency_gap"
             android:layout_width="match_parent"
             android:layout_height="wrap_content"
             android:text="@string/currency_has_gap"
-            app:layout_constraintTop_toBottomOf="@id/position_radio_group"
-            app:layout_constraintStart_toStartOf="parent"
+            app:layout_constraintBottom_toBottomOf="parent"
             app:layout_constraintEnd_toEndOf="parent"
-            app:layout_constraintBottom_toBottomOf="parent"/>
+            app:layout_constraintStart_toStartOf="parent"
+            app:layout_constraintTop_toBottomOf="@id/position_radio_group"
+            />
 
     </androidx.constraintlayout.widget.ConstraintLayout>
 
index 1311465aef0f5335e187e47dc938a3891ac86ff7..6ddb6b10fb8e5f0568574317acad5be9916a6c62 100644 (file)
@@ -1,5 +1,5 @@
 <!--
-  ~ Copyright © 2020 Damyan Ivanov.
+  ~ Copyright © 2021 Damyan Ivanov.
   ~ This file is part of MoLe.
   ~ MoLe is free software: you can distribute it and/or modify it
   ~ under the term of the GNU General Public License as published by
@@ -24,7 +24,8 @@
         android:layout_width="match_parent"
         android:layout_height="match_parent"
         android:animateLayoutChanges="true"
-        tools:context="net.ktnx.mobileledger.ui.activity.NewTransactionActivity">
+        tools:context="net.ktnx.mobileledger.ui.new_transaction.NewTransactionActivity"
+        >
 
         <ProgressBar
             android:id="@+id/progressBar"
 
     </androidx.constraintlayout.widget.ConstraintLayout>
 
-    <com.google.android.material.floatingactionbutton.FloatingActionButton
-        android:id="@+id/fab"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_gravity="bottom|end"
-        android:layout_marginEnd="@dimen/fab_margin"
-        android:layout_marginBottom="@dimen/fab_margin"
-        android:padding="@dimen/fab_margin"
-        android:tint="?android:attr/colorBackground"
-        android:visibility="visible"
-        app:backgroundTint="?colorSecondary"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:srcCompat="@drawable/ic_save_white_24dp" />
 </androidx.coordinatorlayout.widget.CoordinatorLayout>
\ No newline at end of file
index 18b2b59a2d321b0a60d1a325a40b7705627f22d9..f086806d814521e6dda174a46096ed6ad0ce140d 100644 (file)
@@ -65,7 +65,6 @@
         android:indeterminate="true"
         android:indeterminateTint="?colorPrimary"
         android:progressTint="?colorPrimary"
-        app:growMode="incoming"
         app:layout_constraintBottom_toBottomOf="@id/textView4"
         app:layout_constraintEnd_toStartOf="@id/textView4"
         app:layout_constraintTop_toTopOf="@id/textView4"
diff --git a/app/src/main/res/layout/fragment_template_detail_source_selector.xml b/app/src/main/res/layout/fragment_template_detail_source_selector.xml
new file mode 100644 (file)
index 0000000..d3af166
--- /dev/null
@@ -0,0 +1,60 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Copyright © 2021 Damyan Ivanov.
+  ~ This file is part of MoLe.
+  ~ MoLe is free software: you can distribute it and/or modify it
+  ~ under the term of the GNU General Public License as published by
+  ~ the Free Software Foundation, either version 3 of the License, or
+  ~ (at your opinion), any later version.
+  ~
+  ~ MoLe is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  ~ GNU General Public License terms for details.
+  ~
+  ~ You should have received a copy of the GNU General Public License
+  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+  -->
+
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:longClickable="false"
+    >
+
+    <TextView
+        android:id="@+id/group_number"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_margin="@dimen/text_margin"
+        android:gravity="end"
+        android:minWidth="20sp"
+        android:text="1"
+        android:textAppearance="?attr/textAppearanceListItem"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        tools:ignore="HardcodedText"
+        />
+    <TextView
+        android:id="@+id/colon"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="@dimen/text_margin"
+        android:text=":"
+        android:textAppearance="?attr/textAppearanceListItem"
+        app:layout_constraintStart_toEndOf="@id/group_number"
+        app:layout_constraintTop_toTopOf="parent"
+        tools:ignore="HardcodedText"
+        />
+    <TextView
+        android:id="@+id/matched_text"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_margin="@dimen/text_margin"
+        android:textAppearance="?attr/textAppearanceListItem"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toEndOf="@id/colon"
+        app:layout_constraintTop_toTopOf="parent"
+        />
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/app/src/main/res/layout/fragment_template_detail_source_selector_list.xml b/app/src/main/res/layout/fragment_template_detail_source_selector_list.xml
new file mode 100644 (file)
index 0000000..f21a43d
--- /dev/null
@@ -0,0 +1,78 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Copyright © 2021 Damyan Ivanov.
+  ~ This file is part of MoLe.
+  ~ MoLe is free software: you can distribute it and/or modify it
+  ~ under the term of the GNU General Public License as published by
+  ~ the Free Software Foundation, either version 3 of the License, or
+  ~ (at your opinion), any later version.
+  ~
+  ~ MoLe is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  ~ GNU General Public License terms for details.
+  ~
+  ~ You should have received a copy of the GNU General Public License
+  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+  -->
+
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:animateLayoutChanges="true"
+    android:minWidth="60dp"
+    android:padding="@dimen/text_margin"
+    app:layout_constraintWidth_min="60dp"
+    >
+
+    <com.google.android.material.textview.MaterialTextView
+        android:id="@+id/label"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginBottom="@dimen/text_margin"
+        android:text="@string/choose_template_detail_source_label"
+        android:textSize="18sp"
+        app:layout_constraintBottom_toTopOf="@id/list"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        />
+
+    <androidx.recyclerview.widget.RecyclerView
+        android:id="@+id/list"
+        android:name="net.ktnx.mobileledger.ui.PatternDetailSourceSelectorFragment"
+        android:layout_width="0dp"
+        android:layout_height="0dp"
+        app:layout_constraintWidth_min="50dp"
+        app:layout_constraintHeight_min="150dp"
+        android:layout_marginLeft="@dimen/activity_horizontal_margin"
+        android:layout_marginRight="@dimen/activity_horizontal_margin"
+        android:minHeight="100dp"
+        app:layoutManager="LinearLayoutManager"
+        app:layout_constraintBottom_toTopOf="@id/template_error"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/label"
+        tools:context="net.ktnx.mobileledger.ui.CurrencySelectorFragment"
+        tools:listitem="@layout/fragment_template_detail_source_selector"
+        />
+    <TextView
+        android:id="@+id/template_error"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        app:layout_constraintBottom_toTopOf="@id/literal_button"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/list"
+        />
+    <com.google.android.material.button.MaterialButton
+        android:id="@+id/literal_button"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="@dimen/text_margin"
+        android:text="@string/template_details_source_literal"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        />
+</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_template_list.xml b/app/src/main/res/layout/fragment_template_list.xml
new file mode 100644 (file)
index 0000000..a81e9fb
--- /dev/null
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Copyright © 2021 Damyan Ivanov.
+  ~ This file is part of MoLe.
+  ~ MoLe is free software: you can distribute it and/or modify it
+  ~ under the term of the GNU General Public License as published by
+  ~ the Free Software Foundation, either version 3 of the License, or
+  ~ (at your opinion), any later version.
+  ~
+  ~ MoLe is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  ~ GNU General Public License terms for details.
+  ~
+  ~ You should have received a copy of the GNU General Public License
+  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+  -->
+
+<androidx.recyclerview.widget.RecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/template_list"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context=".ui.templates.TemplatesActivity"
+    />
diff --git a/app/src/main/res/layout/main_app_layout.xml b/app/src/main/res/layout/main_app_layout.xml
deleted file mode 100644 (file)
index ffee199..0000000
+++ /dev/null
@@ -1,133 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
-  ~ Copyright © 2020 Damyan Ivanov.
-  ~ This file is part of MoLe.
-  ~ MoLe is free software: you can distribute it and/or modify it
-  ~ under the term of the GNU General Public License as published by
-  ~ the Free Software Foundation, either version 3 of the License, or
-  ~ (at your opinion), any later version.
-  ~
-  ~ MoLe is distributed in the hope that it will be useful,
-  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
-  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-  ~ GNU General Public License terms for details.
-  ~
-  ~ You should have received a copy of the GNU General Public License
-  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
-  -->
-
-<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:id="@+id/main_app_layout"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    android:background="?android:attr/colorBackground"
-    android:orientation="vertical"
-    tools:context=".ui.activity.MainActivity">
-
-
-    <com.google.android.material.floatingactionbutton.FloatingActionButton
-        android:id="@+id/btn_add_transaction"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_gravity="bottom|end"
-        android:layout_margin="@dimen/fab_margin"
-        app:backgroundTint="?colorSecondary"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:maxImageSize="36dp"
-        app:srcCompat="@drawable/ic_add_white_24dp"
-        />
-
-    <androidx.drawerlayout.widget.DrawerLayout
-        android:id="@+id/drawer_layout"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent"
-        tools:openDrawer="start">
-
-        <androidx.constraintlayout.widget.ConstraintLayout
-            android:id="@+id/pager_layout"
-            android:layout_width="match_parent"
-            android:layout_height="match_parent">
-
-            <androidx.appcompat.widget.Toolbar
-                android:id="@+id/toolbar"
-                android:layout_width="match_parent"
-                android:layout_height="@dimen/toolbar_height"
-                android:background="?colorPrimary"
-                android:theme="@style/AppTheme.AppBarOverlay"
-                app:popupTheme="@style/AppTheme.PopupOverlay"
-                app:layout_constraintTop_toTopOf="parent"
-                app:layout_constraintStart_toStartOf="parent"
-                app:layout_constraintEnd_toEndOf="parent"/>
-
-            <LinearLayout
-                android:id="@+id/main_header"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:orientation="vertical"
-                app:layout_constraintEnd_toEndOf="parent"
-                app:layout_constraintStart_toStartOf="parent"
-                app:layout_constraintTop_toBottomOf="@id/toolbar">
-
-                <LinearLayout
-                    android:id="@+id/transaction_progress_layout"
-                    android:layout_width="match_parent"
-                    android:layout_height="wrap_content"
-                    android:gravity="center_vertical"
-                    android:orientation="horizontal"
-                    android:visibility="gone">
-
-                    <ProgressBar
-                        android:id="@+id/transaction_list_progress_bar"
-                        style="?android:attr/progressBarStyleHorizontal"
-                        android:layout_width="0dp"
-                        android:layout_height="wrap_content"
-                        android:layout_marginTop="-8dp"
-                        android:layout_marginBottom="-7dp"
-                        android:layout_weight="1"
-                        android:indeterminate="true"
-                        android:padding="0dp"
-                        android:min="0"
-                        android:progressTint="?colorPrimary"
-                        app:layout_constraintEnd_toEndOf="parent"
-                        app:layout_constraintStart_toStartOf="parent" />
-
-                    <TextView
-                        android:id="@+id/transaction_list_cancel_download"
-                        android:layout_width="wrap_content"
-                        android:layout_height="wrap_content"
-                        android:background="@drawable/ic_clear_accent_24dp"
-                        android:clickable="true"
-                        android:focusable="true"
-                        android:onClick="onStopTransactionRefreshClick" />
-                </LinearLayout>
-
-            </LinearLayout>
-
-            <androidx.viewpager.widget.ViewPager
-                android:id="@+id/root_frame"
-                android:layout_width="match_parent"
-                android:layout_height="0dp"
-                app:layout_constraintBottom_toBottomOf="parent"
-                app:layout_constraintEnd_toEndOf="parent"
-                app:layout_constraintStart_toStartOf="parent"
-                app:layout_constraintTop_toBottomOf="@+id/main_header">
-
-            </androidx.viewpager.widget.ViewPager>
-
-            <View
-                android:layout_width="0dp"
-                android:layout_height="?attr/main_header_shadow_height"
-                android:background="@drawable/drop_shadow"
-                app:layout_constraintEnd_toEndOf="parent"
-                app:layout_constraintStart_toStartOf="parent"
-                app:layout_constraintTop_toBottomOf="@id/main_header" />
-
-
-        </androidx.constraintlayout.widget.ConstraintLayout>
-
-        <include layout="@layout/main_navigation" />
-
-    </androidx.drawerlayout.widget.DrawerLayout>
-</androidx.coordinatorlayout.widget.CoordinatorLayout>
\ No newline at end of file
diff --git a/app/src/main/res/layout/main_navigation.xml b/app/src/main/res/layout/main_navigation.xml
deleted file mode 100644 (file)
index c15c746..0000000
+++ /dev/null
@@ -1,134 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
-  ~ Copyright © 2020 Damyan Ivanov.
-  ~ This file is part of MoLe.
-  ~ MoLe is free software: you can distribute it and/or modify it
-  ~ under the term of the GNU General Public License as published by
-  ~ the Free Software Foundation, either version 3 of the License, or
-  ~ (at your opinion), any later version.
-  ~
-  ~ MoLe is distributed in the hope that it will be useful,
-  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
-  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-  ~ GNU General Public License terms for details.
-  ~
-  ~ You should have received a copy of the GNU General Public License
-  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
-  -->
-
-<com.google.android.material.navigation.NavigationView xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    android:id="@+id/nav_view"
-    android:layout_width="wrap_content"
-    android:layout_height="match_parent"
-    android:layout_gravity="start"
-    android:fitsSystemWindows="true"
-    >
-
-
-    <androidx.constraintlayout.widget.ConstraintLayout
-        android:layout_width="match_parent"
-        android:layout_height="match_parent"
-        android:layout_marginBottom="0dp"
-        android:animateLayoutChanges="true"
-        android:orientation="vertical">
-
-        <LinearLayout
-            android:id="@+id/nav_fixed_items"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:divider="@drawable/list_divider"
-            android:elevation="2dp"
-            android:orientation="vertical"
-            android:showDividers="beginning"
-            android:visibility="gone"
-            app:layout_constraintBottom_toBottomOf="parent">
-
-            <TextView
-                android:id="@+id/textView2"
-                style="@style/nav_button"
-                android:layout_weight="1"
-                android:drawableStart="@drawable/ic_settings_black_24dp"
-                android:text="@string/action_settings" />
-
-        </LinearLayout>
-
-        <ScrollView
-            android:layout_width="0dp"
-            android:layout_height="0dp"
-            app:layout_constraintBottom_toTopOf="@+id/nav_fixed_items"
-            app:layout_constraintEnd_toEndOf="parent"
-            app:layout_constraintLeft_toLeftOf="parent"
-            app:layout_constraintStart_toStartOf="parent"
-            app:layout_constraintTop_toTopOf="parent">
-
-            <LinearLayout
-                android:id="@+id/nav_upper"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:animateLayoutChanges="true"
-                android:orientation="vertical"
-                android:showDividers="beginning"
-                app:layout_constraintBottom_toTopOf="@+id/nav_fixed_items"
-                app:layout_constraintTop_toBottomOf="@+id/nav_header">
-
-                <include layout="@layout/nav_header_layout" />
-
-                <LinearLayout
-                    android:id="@+id/nav_actions"
-                    android:layout_width="match_parent"
-                    android:layout_height="match_parent"
-                    android:orientation="vertical">
-
-                    <TextView
-                        android:id="@+id/nav_account_summary"
-                        style="@style/nav_button"
-                        android:drawableStart="@drawable/ic_home_black_24dp"
-                        android:onClick="onAccountSummaryClicked"
-                        android:text="@string/account_summary_title" />
-
-                    <TextView
-                        android:id="@+id/nav_latest_transactions"
-                        style="@style/nav_button"
-                        android:drawableStart="@drawable/ic_event_note_black_24dp"
-                        android:onClick="onLatestTransactionsClicked"
-                        android:text="@string/nav_transactions_title" />
-
-                    <TextView
-                        android:id="@+id/textView5"
-                        style="@style/nav_button"
-                        android:drawableStart="@drawable/ic_assignment_black_24dp"
-                        android:text="@string/nav_reports_title"
-                        android:visibility="gone" />
-
-                    <include
-                        android:id="@+id/nav_profile_list_head_layout"
-                        layout="@layout/nav_profile_list_head" />
-
-                    <LinearLayout
-                        android:id="@+id/nav_profile_list_container"
-                        android:layout_width="match_parent"
-                        android:layout_height="wrap_content"
-                        android:animateLayoutChanges="true"
-                        android:nestedScrollingEnabled="false"
-                        android:orientation="vertical">
-
-                        <androidx.recyclerview.widget.RecyclerView
-                            android:id="@+id/nav_profile_list"
-                            android:layout_width="match_parent"
-                            android:layout_height="wrap_content"
-                            android:isScrollContainer="false"
-                            android:nestedScrollingEnabled="false"
-                            android:orientation="vertical">
-
-                        </androidx.recyclerview.widget.RecyclerView>
-
-                    </LinearLayout>
-
-                </LinearLayout>
-
-            </LinearLayout>
-        </ScrollView>
-
-    </androidx.constraintlayout.widget.ConstraintLayout>
-
-</com.google.android.material.navigation.NavigationView>
\ No newline at end of file
index 88302e873d42e354141e76f00e9545da80cad0d3..7549fdf18a89605655cc4acca59b18551bf101e3 100644 (file)
@@ -27,7 +27,7 @@
     android:orientation="horizontal"
     android:padding="@dimen/activity_vertical_margin"
     app:layout_constraintTop_toTopOf="parent"
-    tools:showIn="@layout/main_navigation">
+    >
 
     <include layout="@layout/nav_header_logo" />
 
@@ -37,7 +37,8 @@
         android:layout_marginStart="@dimen/activity_horizontal_margin"
         android:layout_marginEnd="@dimen/activity_horizontal_margin"
         android:gravity="center_vertical"
-        android:orientation="vertical">
+        android:orientation="vertical"
+        >
 
         <TextView
             android:layout_width="match_parent"
diff --git a/app/src/main/res/layout/nav_profile_list_head.xml b/app/src/main/res/layout/nav_profile_list_head.xml
deleted file mode 100644 (file)
index 7530df6..0000000
+++ /dev/null
@@ -1,92 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-
-<!--
-  ~ Copyright © 2019 Damyan Ivanov.
-  ~ This file is part of MoLe.
-  ~ MoLe is free software: you can distribute it and/or modify it
-  ~ under the term of the GNU General Public License as published by
-  ~ the Free Software Foundation, either version 3 of the License, or
-  ~ (at your opinion), any later version.
-  ~
-  ~ MoLe is distributed in the hope that it will be useful,
-  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
-  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-  ~ GNU General Public License terms for details.
-  ~
-  ~ You should have received a copy of the GNU General Public License
-  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
-  -->
-
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    android:layout_width="match_parent"
-    android:layout_height="@dimen/thumb_row_height">
-
-    <ImageView
-        android:id="@+id/nav_new_profile_button"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_gravity="center"
-        android:paddingStart="8dp"
-        android:paddingEnd="8dp"
-        android:visibility="gone"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toStartOf="@id/nav_profile_list_head_buttons"
-        app:layout_constraintStart_toEndOf="@id/nav_profiles_label"
-        app:layout_constraintTop_toTopOf="parent"
-        app:srcCompat="@drawable/ic_add_circle_white_24dp"
-        android:contentDescription="@string/icon" />
-
-    <LinearLayout
-        android:id="@+id/nav_profile_list_head_buttons"
-        android:layout_width="wrap_content"
-        android:layout_height="0dp"
-        android:gravity="center_vertical"
-        android:orientation="horizontal"
-        android:paddingStart="16dp"
-        android:paddingEnd="16dp"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintTop_toTopOf="parent">
-
-        <ImageView
-            android:id="@+id/nav_profiles_cancel_edit"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:background="@drawable/ic_clear_accent_24dp"
-            android:gravity="end|center_vertical"
-            android:paddingStart="8dp"
-            android:paddingEnd="8dp"
-            android:visibility="gone"
-            app:layout_constraintBottom_toBottomOf="parent"
-            app:layout_constraintEnd_toEndOf="parent"
-            app:layout_constraintTop_toTopOf="parent"
-            android:contentDescription="@string/icon" />
-
-        <ImageView
-            android:id="@+id/nav_profiles_start_edit"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:background="@drawable/ic_settings_black_24dp"
-            android:gravity="end|center_vertical"
-            android:paddingStart="8dp"
-            android:paddingEnd="8dp"
-            app:layout_constraintBottom_toBottomOf="parent"
-            app:layout_constraintEnd_toEndOf="parent"
-            app:layout_constraintTop_toTopOf="parent"
-            android:contentDescription="@string/icon" />
-
-    </LinearLayout>
-
-    <TextView
-        android:id="@+id/nav_profiles_label"
-        style="@style/nav_button"
-        android:layout_width="wrap_content"
-        android:layout_height="0dp"
-        android:gravity="start|center_vertical"
-        android:text="@string/profiles"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
diff --git a/app/src/main/res/layout/new_transaction_account_row.xml b/app/src/main/res/layout/new_transaction_account_row.xml
new file mode 100644 (file)
index 0000000..0092a0e
--- /dev/null
@@ -0,0 +1,136 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Copyright © 2021 Damyan Ivanov.
+  ~ This file is part of MoLe.
+  ~ MoLe is free software: you can distribute it and/or modify it
+  ~ under the term of the GNU General Public License as published by
+  ~ the Free Software Foundation, either version 3 of the License, or
+  ~ (at your opinion), any later version.
+  ~
+  ~ MoLe is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  ~ GNU General Public License terms for details.
+  ~
+  ~ You should have received a copy of the GNU General Public License
+  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+  -->
+
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:animateLayoutChanges="true"
+    >
+
+    <TextView
+        android:id="@+id/dummy_text"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:visibility="gone"
+        tools:ignore="MissingConstraints"
+        />
+    <net.ktnx.mobileledger.ui.AutoCompleteTextViewWithClear
+        android:id="@+id/account_row_acc_name"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_weight="9"
+        android:width="0dp"
+        android:hint="@string/new_transaction_account_hint"
+        android:imeOptions="actionNext"
+        android:inputType="text"
+        android:selectAllOnFocus="false"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        />
+
+    <androidx.constraintlayout.widget.ConstraintLayout
+        android:id="@+id/comment_layout"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        app:layout_constraintEnd_toStartOf="@id/amount_layout"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/account_row_acc_name"
+        >
+
+        <TextView
+            android:id="@+id/account_comment_button"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:background="@drawable/ic_comment_gray_24dp"
+            app:layout_constraintBottom_toBottomOf="parent"
+            app:layout_constraintStart_toStartOf="parent"
+            app:layout_constraintTop_toTopOf="parent"
+            />
+
+        <net.ktnx.mobileledger.ui.EditTextWithClear
+            android:id="@+id/comment"
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:imeOptions="actionNext"
+            android:inputType="text"
+            android:visibility="invisible"
+            app:layout_constraintEnd_toEndOf="parent"
+            app:layout_constraintStart_toEndOf="@id/account_comment_button"
+            app:layout_constraintTop_toTopOf="parent"
+            />
+    </androidx.constraintlayout.widget.ConstraintLayout>
+
+    <androidx.constraintlayout.widget.ConstraintLayout
+        android:id="@+id/amount_layout"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/account_row_acc_name"
+        >
+
+        <TextView
+            android:id="@+id/currencyButton"
+            android:layout_width="wrap_content"
+            android:layout_height="0dp"
+            android:minWidth="30dp"
+            app:layout_constraintBottom_toBottomOf="parent"
+            app:layout_constraintEnd_toEndOf="@id/currency"
+            app:layout_constraintStart_toStartOf="@id/currency"
+            app:layout_constraintTop_toTopOf="parent"
+            />
+        <EditText
+            android:id="@+id/account_row_acc_amounts"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_gravity="bottom|end"
+            android:layout_weight="0"
+            android:width="0dp"
+            android:foregroundGravity="bottom"
+            android:gravity="bottom|end"
+            android:hint="@string/zero_amount"
+            android:imeOptions="actionNext"
+            android:inputType="number|numberSigned|numberDecimal"
+            android:minEms="4"
+            android:selectAllOnFocus="true"
+            android:textAlignment="viewEnd"
+            app:layout_constraintBottom_toBottomOf="parent"
+            app:layout_constraintLeft_toLeftOf="parent"
+            app:layout_constraintRight_toLeftOf="@id/currency"
+            app:layout_constraintTop_toTopOf="parent"
+            app:layout_constraintWidth_min="@dimen/text_margin"
+            />
+
+        <TextView
+            android:id="@+id/currency"
+            style="@style/TextAppearance.AppCompat.Widget.Button"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_gravity="center_vertical"
+            android:gravity="center_horizontal"
+            android:minWidth="30dp"
+            android:text="@string/currency_symbol"
+            android:textAllCaps="false"
+            app:layout_constraintBottom_toBottomOf="parent"
+            app:layout_constraintRight_toRightOf="parent"
+            app:layout_constraintTop_toTopOf="parent"
+            />
+    </androidx.constraintlayout.widget.ConstraintLayout>
+
+</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
diff --git a/app/src/main/res/layout/new_transaction_header_row.xml b/app/src/main/res/layout/new_transaction_header_row.xml
new file mode 100644 (file)
index 0000000..9f0ff19
--- /dev/null
@@ -0,0 +1,110 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Copyright © 2021 Damyan Ivanov.
+  ~ This file is part of MoLe.
+  ~ MoLe is free software: you can distribute it and/or modify it
+  ~ under the term of the GNU General Public License as published by
+  ~ the Free Software Foundation, either version 3 of the License, or
+  ~ (at your opinion), any later version.
+  ~
+  ~ MoLe is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  ~ GNU General Public License terms for details.
+  ~
+  ~ You should have received a copy of the GNU General Public License
+  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+  -->
+
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:animateLayoutChanges="true"
+    android:orientation="horizontal"
+    >
+
+    <TextView
+        android:id="@+id/dummy_text"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:visibility="gone"
+        tools:ignore="MissingConstraints"
+        />
+    <EditText
+        android:id="@+id/new_transaction_date"
+        android:layout_width="94dp"
+        android:layout_height="wrap_content"
+        android:accessibilityTraversalBefore="@+id/new_transaction_description"
+        android:drawableStart="@drawable/ic_event_gray_24dp"
+        android:enabled="true"
+        android:focusable="false"
+        android:gravity="bottom|center"
+        android:hint="@string/new_transaction_date_hint"
+        android:inputType="none"
+        android:nextFocusDown="@+id/new_transaction_acc_1"
+        android:nextFocusForward="@+id/new_transaction_description"
+        android:textAlignment="gravity"
+        android:textCursorDrawable="@android:color/transparent"
+        app:layout_constrainedHeight="true"
+        app:layout_constraintHorizontal_weight="8"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        tools:ignore="TextFields"
+        />
+
+    <net.ktnx.mobileledger.ui.AutoCompleteTextViewWithClear
+        android:id="@+id/new_transaction_description"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_gravity="bottom"
+        android:layout_marginStart="8dp"
+        android:accessibilityTraversalAfter="@+id/new_transaction_date"
+        android:foregroundGravity="bottom"
+        android:gravity="bottom"
+        android:hint="@string/new_transaction_description_hint"
+        android:imeOptions="actionNext"
+        android:inputType="text"
+        android:nextFocusLeft="@+id/new_transaction_date"
+        android:nextFocusUp="@+id/new_transaction_date"
+        android:selectAllOnFocus="false"
+        android:singleLine="true"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintHorizontal_weight="30"
+        app:layout_constraintStart_toEndOf="@id/new_transaction_date"
+        app:layout_constraintTop_toTopOf="parent"
+        />
+
+    <androidx.constraintlayout.widget.ConstraintLayout
+        android:id="@+id/transaction_comment_layout"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/new_transaction_description"
+        >
+
+        <TextView
+            android:id="@+id/transaction_comment_button"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:background="@drawable/ic_comment_gray_24dp"
+            app:layout_constraintBottom_toBottomOf="parent"
+            app:layout_constraintStart_toStartOf="parent"
+            app:layout_constraintTop_toTopOf="parent"
+            />
+
+        <net.ktnx.mobileledger.ui.EditTextWithClear
+            android:id="@+id/transaction_comment"
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:imeOptions="actionNext"
+            android:inputType="text"
+            android:visibility="invisible"
+            app:layout_constraintEnd_toEndOf="parent"
+            app:layout_constraintStart_toEndOf="@id/transaction_comment_button"
+            app:layout_constraintTop_toTopOf="parent"
+            />
+    </androidx.constraintlayout.widget.ConstraintLayout>
+
+</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
diff --git a/app/src/main/res/layout/new_transaction_row.xml b/app/src/main/res/layout/new_transaction_row.xml
deleted file mode 100644 (file)
index a521d4a..0000000
+++ /dev/null
@@ -1,217 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
-  ~ Copyright © 2020 Damyan Ivanov.
-  ~ This file is part of MoLe.
-  ~ MoLe is free software: you can distribute it and/or modify it
-  ~ under the term of the GNU General Public License as published by
-  ~ the Free Software Foundation, either version 3 of the License, or
-  ~ (at your opinion), any later version.
-  ~
-  ~ MoLe is distributed in the hope that it will be useful,
-  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
-  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-  ~ GNU General Public License terms for details.
-  ~
-  ~ You should have received a copy of the GNU General Public License
-  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
-  -->
-
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="wrap_content"
-    android:animateLayoutChanges="true"
-    android:orientation="vertical">
-
-    <androidx.constraintlayout.widget.ConstraintLayout
-        android:id="@+id/ntr_data"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:animateLayoutChanges="true"
-        android:orientation="horizontal"
-        >
-
-        <TextView
-            android:id="@+id/dummy_text"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:visibility="gone"
-            tools:ignore="MissingConstraints"
-            />
-        <EditText
-            android:id="@+id/new_transaction_date"
-            android:layout_width="94dp"
-            android:layout_height="wrap_content"
-            android:accessibilityTraversalBefore="@+id/new_transaction_description"
-            android:drawableStart="@drawable/ic_event_gray_24dp"
-            android:enabled="true"
-            android:focusable="false"
-            android:gravity="bottom|center"
-            android:hint="@string/new_transaction_date_hint"
-            android:inputType="none"
-            android:nextFocusDown="@+id/new_transaction_acc_1"
-            android:nextFocusForward="@+id/new_transaction_description"
-            android:textAlignment="gravity"
-            android:textCursorDrawable="@android:color/transparent"
-            app:layout_constrainedHeight="true"
-            app:layout_constraintHorizontal_weight="8"
-            app:layout_constraintStart_toStartOf="parent"
-            app:layout_constraintTop_toTopOf="parent"
-            tools:ignore="TextFields" />
-
-        <net.ktnx.mobileledger.ui.AutoCompleteTextViewWithClear
-            android:id="@+id/new_transaction_description"
-            android:layout_width="0dp"
-            android:layout_height="wrap_content"
-            android:layout_gravity="bottom"
-            android:layout_marginStart="8dp"
-            android:accessibilityTraversalAfter="@+id/new_transaction_date"
-            android:foregroundGravity="bottom"
-            android:gravity="bottom"
-            android:hint="@string/new_transaction_description_hint"
-            android:imeOptions="actionNext"
-            android:inputType="text"
-            android:nextFocusLeft="@+id/new_transaction_date"
-            android:nextFocusUp="@+id/new_transaction_date"
-            android:selectAllOnFocus="false"
-            android:singleLine="true"
-            app:layout_constraintEnd_toEndOf="parent"
-            app:layout_constraintHorizontal_weight="30"
-            app:layout_constraintStart_toEndOf="@id/new_transaction_date"
-            app:layout_constraintTop_toTopOf="parent" />
-
-        <androidx.constraintlayout.widget.ConstraintLayout
-            android:id="@+id/transaction_comment_layout"
-            android:layout_width="0dp"
-            android:layout_height="wrap_content"
-            app:layout_constraintEnd_toEndOf="parent"
-            app:layout_constraintStart_toStartOf="parent"
-            app:layout_constraintTop_toBottomOf="@id/new_transaction_description">
-
-            <TextView
-                android:id="@+id/comment_button"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:background="@drawable/ic_comment_gray_24dp"
-                app:layout_constraintBottom_toBottomOf="parent"
-                app:layout_constraintStart_toStartOf="parent"
-                app:layout_constraintTop_toTopOf="parent" />
-
-            <net.ktnx.mobileledger.ui.EditTextWithClear
-                android:id="@+id/transaction_comment"
-                android:layout_width="0dp"
-                android:layout_height="wrap_content"
-                android:imeOptions="actionNext"
-                android:inputType="text"
-                android:visibility="invisible"
-                app:layout_constraintEnd_toEndOf="parent"
-                app:layout_constraintStart_toEndOf="@id/comment_button"
-                app:layout_constraintTop_toTopOf="parent" />
-        </androidx.constraintlayout.widget.ConstraintLayout>
-
-    </androidx.constraintlayout.widget.ConstraintLayout>
-
-    <androidx.constraintlayout.widget.ConstraintLayout
-        android:id="@+id/ntr_account"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:animateLayoutChanges="true">
-
-        <net.ktnx.mobileledger.ui.AutoCompleteTextViewWithClear
-            android:id="@+id/account_row_acc_name"
-            android:layout_width="0dp"
-            android:layout_height="wrap_content"
-            android:layout_weight="9"
-            android:width="0dp"
-            android:hint="@string/new_transaction_account_hint"
-            android:imeOptions="actionNext"
-            android:inputType="text"
-            android:selectAllOnFocus="false"
-            app:layout_constraintEnd_toEndOf="parent"
-            app:layout_constraintStart_toStartOf="parent"
-            app:layout_constraintTop_toTopOf="parent" />
-
-        <androidx.constraintlayout.widget.ConstraintLayout
-            android:id="@+id/comment_layout"
-            android:layout_width="0dp"
-            android:layout_height="wrap_content"
-            app:layout_constraintEnd_toStartOf="@id/amount_layout"
-            app:layout_constraintStart_toStartOf="parent"
-            app:layout_constraintTop_toBottomOf="@id/account_row_acc_name">
-
-            <TextView
-                android:id="@id/comment_button"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:background="@drawable/ic_comment_gray_24dp"
-                app:layout_constraintBottom_toBottomOf="parent"
-                app:layout_constraintStart_toStartOf="parent"
-                app:layout_constraintTop_toTopOf="parent" />
-
-            <net.ktnx.mobileledger.ui.EditTextWithClear
-                android:id="@+id/comment"
-                android:layout_width="0dp"
-                android:layout_height="wrap_content"
-                android:imeOptions="actionNext"
-                android:inputType="text"
-                android:visibility="invisible"
-                app:layout_constraintEnd_toEndOf="parent"
-                app:layout_constraintStart_toEndOf="@id/comment_button"
-                app:layout_constraintTop_toTopOf="parent" />
-        </androidx.constraintlayout.widget.ConstraintLayout>
-
-        <androidx.constraintlayout.widget.ConstraintLayout
-            android:id="@+id/amount_layout"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            app:layout_constraintEnd_toEndOf="parent"
-            app:layout_constraintTop_toBottomOf="@id/account_row_acc_name">
-
-            <EditText
-                android:id="@+id/account_row_acc_amounts"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_gravity="bottom|end"
-                android:layout_weight="0"
-                android:width="0dp"
-                android:foregroundGravity="bottom"
-                android:gravity="bottom|end"
-                android:hint="@string/zero_amount"
-                android:imeOptions="actionNext"
-                android:inputType="number|numberSigned|numberDecimal"
-                android:minEms="4"
-                android:selectAllOnFocus="true"
-                android:textAlignment="viewEnd"
-                app:layout_constraintBottom_toBottomOf="parent"
-                app:layout_constraintLeft_toLeftOf="parent"
-                app:layout_constraintRight_toLeftOf="@id/currency"
-                app:layout_constraintTop_toTopOf="parent"
-                app:layout_constraintWidth_min="@dimen/text_margin" />
-
-            <TextView
-                android:id="@+id/currency"
-                style="@style/TextAppearance.AppCompat.Widget.Button"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_gravity="center_vertical"
-                android:gravity="center_horizontal"
-                android:minWidth="24dp"
-                android:text="@string/currency_symbol"
-                android:textAllCaps="false"
-                android:visibility="gone"
-                app:layout_constraintBottom_toBottomOf="parent"
-                app:layout_constraintRight_toRightOf="parent"
-                app:layout_constraintTop_toTopOf="parent" />
-        </androidx.constraintlayout.widget.ConstraintLayout>
-
-    </androidx.constraintlayout.widget.ConstraintLayout>
-
-    <FrameLayout
-        android:id="@+id/ntr_padding"
-        android:layout_width="match_parent"
-        android:layout_height="80dp"
-        android:minHeight="80dp">
-
-    </FrameLayout>
-
-</LinearLayout>
\ No newline at end of file
diff --git a/app/src/main/res/layout/no_profiles.xml b/app/src/main/res/layout/no_profiles.xml
deleted file mode 100644 (file)
index 70f3544..0000000
+++ /dev/null
@@ -1,89 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
-  ~ Copyright © 2020 Damyan Ivanov.
-  ~ This file is part of MoLe.
-  ~ MoLe is free software: you can distribute it and/or modify it
-  ~ under the term of the GNU General Public License as published by
-  ~ the Free Software Foundation, either version 3 of the License, or
-  ~ (at your opinion), any later version.
-  ~
-  ~ MoLe is distributed in the hope that it will be useful,
-  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
-  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-  ~ GNU General Public License terms for details.
-  ~
-  ~ You should have received a copy of the GNU General Public License
-  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
-  -->
-
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    android:id="@+id/no_profiles_layout"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    android:background="?table_row_dark_bg"
-    >
-
-    <FrameLayout
-        android:id="@+id/welcome_header"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent">
-
-        <include layout="@layout/nav_header_layout" />
-    </FrameLayout>
-
-    <androidx.constraintlayout.widget.ConstraintLayout
-        android:layout_width="0dp"
-        android:layout_height="0dp"
-        app:layout_constraintTop_toBottomOf="@id/welcome_header"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintBottom_toBottomOf="parent"
-        android:padding="@dimen/activity_horizontal_margin">
-
-        <TextView
-            android:id="@+id/textView"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:layout_marginTop="48dp"
-            android:text="@string/text_welcome"
-            android:textColor="?textColor"
-            android:textSize="48sp"
-            app:layout_constraintEnd_toEndOf="parent"
-            app:layout_constraintStart_toStartOf="parent"
-            app:layout_constraintTop_toTopOf="parent" />
-
-        <TextView
-            android:id="@+id/textView3"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:layout_marginStart="8dp"
-            android:layout_marginTop="24dp"
-            android:layout_marginEnd="8dp"
-            android:text="@string/text_welcome_profile_needed"
-            android:textColor="?textColor"
-            android:textSize="20sp"
-            app:layout_constraintEnd_toEndOf="parent"
-            app:layout_constraintStart_toStartOf="parent"
-            app:layout_constraintTop_toBottomOf="@+id/textView" />
-
-        <Button
-            android:id="@+id/btn_no_profiles_add"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:layout_marginStart="8dp"
-            android:layout_marginTop="24dp"
-            android:layout_marginEnd="8dp"
-            android:layout_marginBottom="8dp"
-            android:backgroundTint="?colorSecondary"
-            android:textColor="@color/design_default_color_on_primary"
-            android:drawablePadding="16dp"
-            android:text="@string/create_profile_label"
-            app:layout_constraintBottom_toBottomOf="parent"
-            app:layout_constraintEnd_toEndOf="parent"
-            app:layout_constraintStart_toStartOf="parent"
-            app:layout_constraintTop_toBottomOf="@+id/textView3" />
-    </androidx.constraintlayout.widget.ConstraintLayout>
-</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
index d2ca5601a89705eb5322bd25b5f0d4da281bc195..f2218a3a789f99e93a0c2d9f108fdcc4ba73b5eb 100644 (file)
     android:layout_height="match_parent"
     android:orientation="vertical"
     android:padding="16dp"
-    tools:context=".ui.profiles.ProfileDetailFragment">
+    tools:context=".ui.profiles.ProfileDetailFragment"
+    >
 
     <com.google.android.material.textfield.TextInputLayout
         android:id="@+id/profile_name_layout"
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
-        android:layout_marginBottom="16dp">
+        android:layout_marginBottom="16dp"
+        >
 
         <com.google.android.material.textfield.TextInputEditText
             android:id="@+id/profile_name"
             android:layout_width="match_parent"
             android:layout_height="wrap_content"
             android:hint="@string/profile_name_label"
-            android:inputType="textPersonName" />
+            android:inputType="textPersonName"
+            />
     </com.google.android.material.textfield.TextInputLayout>
 
     <com.google.android.material.textfield.TextInputLayout
@@ -45,7 +48,8 @@
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
         android:layout_marginBottom="16dp"
-        android:orientation="vertical">
+        android:orientation="vertical"
+        >
 
         <com.google.android.material.textfield.TextInputEditText
             android:id="@+id/url"
             android:layout_height="wrap_content"
             android:hint="@string/url_label"
             android:inputType="textUri"
-            android:text="@string/pref_default_backend_url" />
+            android:text="@string/pref_default_backend_url"
+            />
     </com.google.android.material.textfield.TextInputLayout>
 
     <LinearLayout
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
         android:animateLayoutChanges="true"
-        android:orientation="vertical">
+        android:orientation="vertical"
+        >
 
-        <Switch
+        <com.google.android.material.switchmaterial.SwitchMaterial
             android:id="@+id/enable_http_auth"
             android:layout_width="match_parent"
             android:layout_height="wrap_content"
             android:layout_marginBottom="16dp"
             android:text="@string/pref_title_use_http_auth"
-            android:textAppearance="?android:textAppearanceListItem" />
+            android:textAppearance="?android:textAppearanceListItem"
+            />
 
         <LinearLayout
             android:id="@+id/auth_params"
@@ -78,7 +85,8 @@
             android:animateLayoutChanges="true"
             android:orientation="vertical"
             android:paddingStart="8dp"
-            tools:ignore="RtlSymmetry">
+            tools:ignore="RtlSymmetry"
+            >
 
             <LinearLayout
                 android:id="@+id/insecure_scheme_text"
                 android:layout_marginBottom="@dimen/activity_vertical_margin"
                 android:background="?colorError"
                 android:padding="@dimen/activity_vertical_margin"
-                android:visibility="gone">
+                android:visibility="gone"
+                >
 
                 <TextView
                     android:layout_width="match_parent"
                     android:layout_height="wrap_content"
+                    android:text="@string/insecure_scheme_with_auth"
                     android:textColor="?colorOnError"
-                    android:text="@string/insecure_scheme_with_auth" />
+                    />
             </LinearLayout>
 
             <com.google.android.material.textfield.TextInputLayout
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
                 android:layout_marginBottom="16dp"
-                android:orientation="vertical">
+                android:orientation="vertical"
+                >
 
                 <com.google.android.material.textfield.TextInputEditText
                     android:id="@+id/auth_user_name"
                     android:layout_width="match_parent"
                     android:layout_height="wrap_content"
                     android:hint="@string/pref_title_backend_auth_user"
-                    android:inputType="textPersonName" />
+                    android:inputType="textPersonName"
+                    />
             </com.google.android.material.textfield.TextInputLayout>
 
             <com.google.android.material.textfield.TextInputLayout
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
                 android:orientation="vertical"
-                app:passwordToggleEnabled="true">
+                app:passwordToggleEnabled="true"
+                >
 
                 <com.google.android.material.textfield.TextInputEditText
                     android:id="@+id/password"
                     android:layout_width="match_parent"
                     android:layout_height="wrap_content"
                     android:hint="@string/pref_title_backend_auth_password"
-                    android:inputType="textWebPassword" />
+                    android:inputType="textWebPassword"
+                    />
 
             </com.google.android.material.textfield.TextInputLayout>
 
         </LinearLayout>
 
         <androidx.constraintlayout.widget.ConstraintLayout
-            android:id="@+id/api_version_layout"
+            android:id="@+id/server_version_layout"
             android:layout_width="match_parent"
             android:layout_height="wrap_content"
-            android:layout_marginBottom="16dp">
+            android:layout_marginBottom="16dp"
+            >
 
             <TextView
-                android:id="@+id/api_version_label"
-                android:layout_width="match_parent"
+                android:id="@+id/server_version_label"
+                android:layout_width="0dp"
                 android:layout_height="wrap_content"
-                android:text="@string/profile_api_version_title"
+                android:text="@string/profile_server_version_title"
                 android:textAppearance="?android:textAppearanceListItem"
+                app:layout_constraintEnd_toStartOf="@id/server_version_detect_button"
+                app:layout_constraintStart_toStartOf="parent"
                 app:layout_constraintTop_toTopOf="parent"
                 />
 
             <TextView
-                android:id="@+id/api_version_text"
+                android:id="@+id/detected_server_version_text"
                 android:layout_width="0dp"
                 android:layout_height="wrap_content"
+                android:layout_marginEnd="8dp"
+                android:gravity="start"
                 android:textAppearance="?android:textAppearanceListItemSecondary"
                 android:textColor="?attr/textColor"
-                app:layout_constraintEnd_toStartOf="@id/detected_version_text"
+                android:text="@string/server_version_unknown_label"
+                app:layout_constraintEnd_toStartOf="@id/server_version_detect_button"
+                app:layout_constraintBottom_toBottomOf="parent"
                 app:layout_constraintStart_toStartOf="parent"
-                app:layout_constraintTop_toBottomOf="@id/api_version_label"
+                app:layout_constraintTop_toBottomOf="@id/server_version_label"
+                />
+            <ProgressBar
+                android:layout_height="24dp"
+                android:id="@+id/server_version_detect_button"
+                android:layout_width="24dp"
+                android:indeterminate="true"
+                android:foregroundGravity="bottom"
+                app:layout_constraintBottom_toBottomOf="parent"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintStart_toEndOf="@id/detected_server_version_text"
+                android:visibility="invisible"
                 />
+        </androidx.constraintlayout.widget.ConstraintLayout>
+
+        <androidx.constraintlayout.widget.ConstraintLayout
+            android:id="@+id/api_version_layout"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginBottom="16dp"
+            >
+
             <TextView
-                android:id="@+id/detected_version_label"
+                android:id="@+id/api_version_label"
                 android:layout_width="0dp"
                 android:layout_height="wrap_content"
-                android:layout_marginEnd="8dp"
-                android:gravity="end"
-                android:text="@string/detected_version_label"
-                android:textAppearance="?android:textAppearanceListItemSecondary"
-                app:layout_constraintEnd_toStartOf="@id/detected_version_text"
+                android:layout_marginEnd="24dp"
+                android:text="@string/profile_api_version_title"
+                android:textAppearance="?android:textAppearanceListItem"
+                app:layout_constraintEnd_toEndOf="parent"
                 app:layout_constraintStart_toStartOf="parent"
-                app:layout_constraintTop_toBottomOf="@id/api_version_text"
+                app:layout_constraintTop_toTopOf="parent"
                 />
+
             <TextView
-                android:id="@+id/detected_version_text"
-                android:layout_width="wrap_content"
+                android:id="@+id/api_version_text"
+                android:layout_width="0dp"
                 android:layout_height="wrap_content"
-                android:layout_marginEnd="8dp"
-                android:text="@string/api_version_unknown_label"
+                android:layout_marginEnd="24dp"
                 android:textAppearance="?android:textAppearanceListItemSecondary"
                 android:textColor="?attr/textColor"
-                app:layout_constraintEnd_toStartOf="@id/api_version_detect_button"
-                app:layout_constraintTop_toBottomOf="@id/api_version_text"
-                />
-            <TextView
-                android:id="@+id/api_version_detect_button"
-                android:layout_width="24dp"
-                android:layout_height="0dp"
-                android:drawableBottom="@drawable/ic_refresh_primary_24dp"
-                android:foregroundGravity="bottom"
-                app:layout_constraintBottom_toBottomOf="parent"
                 app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintStart_toStartOf="parent"
                 app:layout_constraintTop_toBottomOf="@id/api_version_label"
                 />
         </androidx.constraintlayout.widget.ConstraintLayout>
 
-        <Switch
+        <com.google.android.material.switchmaterial.SwitchMaterial
             android:id="@+id/profile_permit_posting"
             android:layout_width="match_parent"
             android:layout_height="wrap_content"
             android:layout_marginBottom="16dp"
             android:text="@string/posting_permitted"
-            android:textAppearance="?android:textAppearanceListItem" />
+            android:textAppearance="?android:textAppearanceListItem"
+            />
 
         <LinearLayout
             android:id="@+id/posting_sub_items"
             android:layout_width="match_parent"
             android:layout_height="wrap_content"
-            android:orientation="vertical">
+            android:orientation="vertical"
+            >
 
             <LinearLayout
                 android:id="@+id/default_commodity_layout"
                 android:layout_marginBottom="16dp"
                 android:clickable="true"
                 android:focusable="true"
-                android:orientation="vertical">
+                android:orientation="vertical"
+                >
 
                 <TextView
                     android:layout_width="match_parent"
                     android:layout_height="wrap_content"
                     android:text="@string/profile_default_commodity"
-                    android:textAppearance="?android:textAppearanceListItem" />
+                    android:textAppearance="?android:textAppearanceListItem"
+                    />
 
                 <TextView
                     android:id="@+id/default_commodity_text"
                     android:layout_height="wrap_content"
                     android:text="@string/btn_no_currency"
                     android:textAppearance="?android:textAppearanceListItemSecondary"
-                    android:textColor="?attr/textColor" />
+                    android:textColor="?attr/textColor"
+                    />
             </LinearLayout>
 
-            <Switch
+            <com.google.android.material.switchmaterial.SwitchMaterial
                 android:id="@+id/profile_show_commodity"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
                 android:layout_marginBottom="16dp"
                 android:text="@string/currency_input_by_default"
-                android:textAppearance="?android:textAppearanceListItem" />
+                android:textAppearance="?android:textAppearanceListItem"
+                />
 
-            <Switch
+            <com.google.android.material.switchmaterial.SwitchMaterial
                 android:id="@+id/profile_show_comments"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
                 android:layout_marginBottom="16dp"
                 android:text="@string/show_comment_input_by_default"
-                android:textAppearance="?android:textAppearanceListItem" />
+                android:textAppearance="?android:textAppearanceListItem"
+                />
 
             <LinearLayout
                 android:id="@+id/future_dates_layout"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
                 android:layout_marginBottom="16dp"
-                android:orientation="vertical">
+                android:orientation="vertical"
+                >
 
                 <TextView
                     android:id="@+id/future_dates_title"
                     android:layout_width="match_parent"
                     android:layout_height="wrap_content"
                     android:text="@string/profile_future_dates_label"
-                    android:textAppearance="?android:textAppearanceListItem" />
+                    android:textAppearance="?android:textAppearanceListItem"
+                    />
 
                 <TextView
                     android:id="@+id/future_dates_text"
                     android:layout_width="match_parent"
                     android:layout_height="wrap_content"
                     android:textAppearance="?android:textAppearanceListItemSecondary"
-                    android:textColor="?attr/textColor" />
+                    android:textColor="?attr/textColor"
+                    />
             </LinearLayout>
 
             <com.google.android.material.textfield.TextInputLayout
-                android:id="@+id/preferred_accounts_accounts_filter_layout"
+                android:id="@+id/preferred_accounts_layout"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
                 android:layout_marginBottom="16dp"
-                android:orientation="vertical">
+                android:orientation="vertical"
+                >
 
                 <com.google.android.material.textfield.TextInputEditText
-                    android:id="@+id/preferred_accounts_filter_filter"
+                    android:id="@+id/preferred_accounts_filter"
                     android:layout_width="match_parent"
                     android:layout_height="wrap_content"
                     android:fontFamily="monospace"
                     android:hint="@string/pref_preferred_autocompletion_account_filter_hint"
                     android:inputType="text"
-                    android:textColor="?attr/editTextColor" />
+                    android:textColor="?attr/editTextColor"
+                    />
             </com.google.android.material.textfield.TextInputLayout>
         </LinearLayout>
 
         <LinearLayout
             android:layout_width="match_parent"
             android:layout_height="match_parent"
-            android:orientation="horizontal">
+            android:orientation="horizontal"
+            >
 
             <TextView
                 android:layout_width="wrap_content"
                 android:layout_weight="100"
                 android:gravity="center_vertical"
                 android:text="@string/profile_color_label"
-                android:textAppearance="?android:textAppearanceListItem" />
+                android:textAppearance="?android:textAppearanceListItem"
+                />
 
             <ImageButton
                 android:id="@+id/btn_pick_ring_color"
                 android:layout_weight="1"
                 android:background="?colorPrimary"
                 android:contentDescription="@string/btn_color_picker_button"
-                app:srcCompat="@drawable/ic_palette_black_24dp"
                 android:tint="?colorOnPrimarySurface"
+                app:srcCompat="@drawable/ic_palette_black_24dp"
                 />
 
         </LinearLayout>
diff --git a/app/src/main/res/layout/profile_list.xml b/app/src/main/res/layout/profile_list.xml
deleted file mode 100644 (file)
index 85a714f..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!--
-  ~ Copyright © 2020 Damyan Ivanov.
-  ~ This file is part of MoLe.
-  ~ MoLe is free software: you can distribute it and/or modify it
-  ~ under the term of the GNU General Public License as published by
-  ~ the Free Software Foundation, either version 3 of the License, or
-  ~ (at your opinion), any later version.
-  ~
-  ~ MoLe is distributed in the hope that it will be useful,
-  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
-  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-  ~ GNU General Public License terms for details.
-  ~
-  ~ You should have received a copy of the GNU General Public License
-  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
-  -->
-
-<androidx.recyclerview.widget.RecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:name="net.ktnx.mobileledger.ui.activity.ProfileListFragment"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    app:layoutManager="LinearLayoutManager"
-    tools:listitem="@layout/profile_list_content" />
\ No newline at end of file
index a4584ad34773866572a1c90fa5ee96cf1a82f22e..2f0d6797ad745c0503643d67a7a62d4dce38d709 100644 (file)
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
 <!--
-  ~ Copyright © 2019 Damyan Ivanov.
+  ~ Copyright © 2021 Damyan Ivanov.
   ~ This file is part of MoLe.
   ~ MoLe is free software: you can distribute it and/or modify it
   ~ under the term of the GNU General Public License as published by
@@ -40,7 +40,7 @@
             android:layout_height="match_parent"
             android:layout_margin="8dp"
             android:layout_weight="9"
-            android:background="@drawable/ic_unfold_more_black_24dp"
+            android:background="@drawable/ic_baseline_drag_handle_24"
             android:contentDescription="@string/profile_list_rearrange_handle_label"
             app:layout_constraintBottom_toBottomOf="parent"
             app:layout_constraintStart_toStartOf="parent"
index 5835f1440fa6376db14e3a21ade7be5d9e1dcf25..e11755d03a0d626f3aa1077a35060f19fb83bbc6 100644 (file)
@@ -1,5 +1,5 @@
 <!--
-  ~ Copyright © 2019 Damyan Ivanov.
+  ~ Copyright © 2020 Damyan Ivanov.
   ~ This file is part of MoLe.
   ~ MoLe is free software: you can distribute it and/or modify it
   ~ under the term of the GNU General Public License as published by
     android:layout_width="match_parent"
     android:layout_height="match_parent">
 
-    <Switch
+    <com.google.android.material.switchmaterial.SwitchMaterial
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
         android:layout_centerHorizontal="true"
-        android:layout_centerVertical="true" />
+        android:layout_centerVertical="true"
+        />
 </RelativeLayout>
diff --git a/app/src/main/res/layout/template_details_account.xml b/app/src/main/res/layout/template_details_account.xml
new file mode 100644 (file)
index 0000000..d91bd57
--- /dev/null
@@ -0,0 +1,235 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Copyright © 2021 Damyan Ivanov.
+  ~ This file is part of MoLe.
+  ~ MoLe is free software: you can distribute it and/or modify it
+  ~ under the term of the GNU General Public License as published by
+  ~ the Free Software Foundation, either version 3 of the License, or
+  ~ (at your opinion), any later version.
+  ~
+  ~ MoLe is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  ~ GNU General Public License terms for details.
+  ~
+  ~ You should have received a copy of the GNU General Public License
+  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+  -->
+
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:id="@+id/pattern_details_item_account_row"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:animateLayoutChanges="true"
+    android:padding="@dimen/text_margin"
+    >
+    <TextView
+        android:id="@+id/pattern_account_label"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:clickable="false"
+        android:text="@string/template_details_account_row_label"
+        android:textAppearance="?attr/textAppearanceListItem"
+        app:drawableBottomCompat="@drawable/dashed_border_8dp"
+        app:drawableStartCompat="@drawable/ic_baseline_drag_handle_24"
+        app:layout_constraintBottom_toTopOf="@id/template_account_name_source_label"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        />
+    <TextView
+        android:id="@+id/template_account_name_source_label"
+        android:layout_width="0dp"
+        android:layout_height="match_parent"
+        android:paddingTop="@dimen/text_margin"
+        android:text="@string/account_name_source_label"
+        android:textAppearance="?attr/textAppearanceListItem"
+        app:layout_constraintBottom_toTopOf="@+id/template_details_account_name_source"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/pattern_account_label"
+        />
+    <TextView
+        android:id="@+id/template_details_account_name_source"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:minWidth="100dp"
+        android:textAppearance="?attr/textAppearanceListItemSecondary"
+        app:layout_constraintBottom_toTopOf="@+id/template_details_account_name_layout"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/template_account_name_source_label"
+        />
+    <com.google.android.material.textfield.TextInputLayout
+        android:id="@+id/template_details_account_name_layout"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginBottom="@dimen/text_margin"
+        android:textAppearance="?attr/textAppearanceListItem"
+        app:endIconMode="clear_text"
+        app:layout_constraintBottom_toTopOf="@+id/template_account_comment_source_label"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/template_details_account_name_source"
+        >
+        <com.google.android.material.textfield.MaterialAutoCompleteTextView
+            android:id="@+id/template_details_account_name"
+            style="@style/MoLeMaterialAutoCompleteTextViewStyle"
+            android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:hint="@string/template_details_account_name_label"
+            android:inputType="text"
+            />
+    </com.google.android.material.textfield.TextInputLayout>
+
+    <TextView
+        android:id="@+id/template_account_comment_source_label"
+        android:layout_width="0dp"
+        android:layout_height="match_parent"
+        android:text="@string/account_comment_source_label"
+        android:textAppearance="?attr/textAppearanceListItem"
+        app:layout_constraintBottom_toTopOf="@+id/template_details_account_comment_source"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/template_details_account_name_layout"
+        />
+    <TextView
+        android:id="@+id/template_details_account_comment_source"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:minWidth="100dp"
+        android:textAppearance="?attr/textAppearanceListItemSecondary"
+        app:layout_constraintBottom_toTopOf="@+id/template_details_account_comment_layout"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/template_account_comment_source_label"
+        />
+    <com.google.android.material.textfield.TextInputLayout
+        android:id="@+id/template_details_account_comment_layout"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginBottom="@dimen/text_margin"
+        android:textAppearance="?attr/textAppearanceListItem"
+        app:endIconMode="clear_text"
+        app:layout_constraintBottom_toTopOf="@+id/template_account_amount_source_label"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/template_details_account_comment_source"
+        >
+        <com.google.android.material.textfield.TextInputEditText
+            android:id="@+id/template_details_account_comment"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:hint="@string/template_details_account_comment_label"
+            android:inputType="text"
+            />
+    </com.google.android.material.textfield.TextInputLayout>
+
+    <TextView
+        android:id="@+id/template_account_amount_source_label"
+        android:layout_width="0dp"
+        android:layout_height="match_parent"
+        android:text="@string/account_amount_source_label"
+        android:textAppearance="?attr/textAppearanceListItem"
+        app:layout_constraintBottom_toTopOf="@+id/template_details_account_amount_source"
+        app:layout_constraintEnd_toStartOf="@id/negate_amount_switch"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/template_details_account_comment_layout"
+        />
+    <TextView
+        android:id="@+id/template_details_account_amount_source"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:textAppearance="?attr/textAppearanceListItemSecondary"
+        app:layout_constraintBottom_toTopOf="@+id/template_details_account_amount_layout"
+        app:layout_constraintEnd_toStartOf="@id/negate_amount_switch"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/template_account_amount_source_label"
+        />
+    <com.google.android.material.textfield.TextInputLayout
+        android:id="@+id/template_details_account_amount_layout"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginBottom="@dimen/text_margin"
+        android:textAppearance="?attr/textAppearanceListItem"
+        app:layout_constraintBottom_toTopOf="@id/template_details_negate_amount_label"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/template_details_account_amount_source"
+        >
+        <com.google.android.material.textfield.TextInputEditText
+            android:id="@+id/template_details_account_amount"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:hint="@string/template_details_account_amount_label"
+            android:inputType="number|numberDecimal|numberSigned"
+            android:selectAllOnFocus="true"
+            />
+    </com.google.android.material.textfield.TextInputLayout>
+
+    <TextView
+        android:id="@+id/template_details_negate_amount_label"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="@dimen/text_margin"
+        android:text="@string/template_account_negate_amount_label"
+        android:textAppearance="?attr/textAppearanceListItem"
+        app:layout_constraintBottom_toTopOf="@id/template_details_negate_amount_text"
+        app:layout_constraintEnd_toStartOf="@+id/negate_amount_switch"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/template_details_account_amount_layout"
+        />
+    <TextView
+        android:id="@+id/template_details_negate_amount_text"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:text="@string/template_account_keep_amount_sign"
+        android:textAppearance="?attr/textAppearanceListItemSecondary"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintBottom_toTopOf="@id/template_details_negate_amount_text"
+        app:layout_constraintEnd_toStartOf="@+id/negate_amount_switch"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/template_details_negate_amount_label"
+        />
+    <com.google.android.material.switchmaterial.SwitchMaterial
+        android:id="@+id/negate_amount_switch"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        app:layout_constraintBottom_toBottomOf="@id/template_details_negate_amount_text"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintTop_toTopOf="@id/template_details_negate_amount_label"
+        />
+
+    <TextView
+        android:id="@+id/template_account_currency_source_label"
+        android:layout_width="0dp"
+        android:layout_height="match_parent"
+        android:layout_marginTop="@dimen/text_margin"
+        android:text="@string/account_currency_source_label"
+        android:textAppearance="?attr/textAppearanceListItem"
+        app:layout_constraintBottom_toTopOf="@+id/template_details_account_currency_source"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/negate_amount_switch"
+        />
+    <TextView
+        android:id="@+id/template_details_account_currency_source"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:textAppearance="?attr/textAppearanceListItemSecondary"
+        app:layout_constraintBottom_toTopOf="@+id/template_details_account_currency"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/template_account_currency_source_label"
+        />
+    <TextView
+        android:id="@+id/template_details_account_currency"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginVertical="@dimen/half_text_margin"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/template_details_account_currency_source"
+        />
+</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
diff --git a/app/src/main/res/layout/template_details_fragment.xml b/app/src/main/res/layout/template_details_fragment.xml
new file mode 100644 (file)
index 0000000..2b7d575
--- /dev/null
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Copyright © 2021 Damyan Ivanov.
+  ~ This file is part of MoLe.
+  ~ MoLe is free software: you can distribute it and/or modify it
+  ~ under the term of the GNU General Public License as published by
+  ~ the Free Software Foundation, either version 3 of the License, or
+  ~ (at your opinion), any later version.
+  ~
+  ~ MoLe is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  ~ GNU General Public License terms for details.
+  ~
+  ~ You should have received a copy of the GNU General Public License
+  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+  -->
+
+<androidx.recyclerview.widget.RecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/pattern_details_recycler_view"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context=".ui.templates.TemplateDetailsFragment"
+    >
+</androidx.recyclerview.widget.RecyclerView>
\ No newline at end of file
diff --git a/app/src/main/res/layout/template_details_header.xml b/app/src/main/res/layout/template_details_header.xml
new file mode 100644 (file)
index 0000000..28409d1
--- /dev/null
@@ -0,0 +1,428 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Copyright © 2021 Damyan Ivanov.
+  ~ This file is part of MoLe.
+  ~ MoLe is free software: you can distribute it and/or modify it
+  ~ under the term of the GNU General Public License as published by
+  ~ the Free Software Foundation, either version 3 of the License, or
+  ~ (at your opinion), any later version.
+  ~
+  ~ MoLe is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  ~ GNU General Public License terms for details.
+  ~
+  ~ You should have received a copy of the GNU General Public License
+  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+  -->
+
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:id="@+id/pattern_details_item_head"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:animateLayoutChanges="true"
+    android:padding="@dimen/text_margin"
+    >
+    <ImageButton
+        android:id="@+id/template_params_help_button"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:background="@android:color/transparent"
+        android:contentDescription="@string/template_params_help_description"
+        android:minWidth="@dimen/thumb_row_height"
+        android:src="@drawable/ic_baseline_help_outline_24_primary"
+        app:layout_constraintBottom_toBottomOf="@id/template_params_label"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintTop_toTopOf="@id/template_params_label"
+        />
+    <TextView
+        android:id="@+id/template_params_label"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginBottom="@dimen/text_margin"
+        android:gravity="end"
+        android:text="@string/template_details_template_params_label"
+        android:textAppearance="@android:style/TextAppearance.Material.Medium"
+        app:layout_constraintBottom_toTopOf="@id/pattern_name_layout"
+        app:layout_constraintEnd_toStartOf="@id/template_params_help_button"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        />
+    <com.google.android.material.textfield.TextInputLayout
+        android:id="@+id/pattern_name_layout"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginBottom="@dimen/text_margin"
+        app:endIconMode="clear_text"
+        app:layout_constraintBottom_toTopOf="@id/pattern_layout"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/template_params_label"
+        >
+        <com.google.android.material.textfield.TextInputEditText
+            android:id="@+id/template_name"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:hint="@string/template_name_label"
+            android:inputType="text"
+            />
+    </com.google.android.material.textfield.TextInputLayout>
+    <com.google.android.material.textfield.TextInputLayout
+        android:id="@+id/pattern_layout"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginBottom="@dimen/text_margin"
+        android:textAppearance="?attr/textAppearanceListItem"
+        app:endIconMode="clear_text"
+        app:layout_constraintBottom_toTopOf="@id/pattern_hint_title"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/pattern_name_layout"
+        >
+        <com.google.android.material.textfield.TextInputEditText
+            android:id="@+id/pattern"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:hint="@string/template_details_pattern_label"
+            android:inputType="text|textMultiLine"
+            />
+    </com.google.android.material.textfield.TextInputLayout>
+    <TextView
+        android:id="@+id/pattern_hint_title"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:text="@string/pattern_match_result"
+        android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
+        app:layout_constraintBottom_toTopOf="@+id/pattern_hint_text"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/pattern_layout"
+        />
+    <TextView
+        android:id="@+id/pattern_hint_text"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginBottom="@dimen/text_margin"
+        android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
+        app:layout_constraintBottom_toTopOf="@+id/test_text_layout"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/pattern_hint_title"
+        />
+    <com.google.android.material.textfield.TextInputLayout
+        android:id="@+id/test_text_layout"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginBottom="@dimen/text_margin"
+        android:textAppearance="?attr/textAppearanceListItem"
+        app:endIconMode="clear_text"
+        app:layout_constraintBottom_toTopOf="@id/transaction_parameters_label"
+        app:layout_constraintEnd_toStartOf="@id/template_details_head_scan_qr_button"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/pattern_hint_text"
+        >
+        <com.google.android.material.textfield.TextInputEditText
+            android:id="@+id/test_text"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:hint="@string/template_details_test_text_label"
+            android:inputType="text|textMultiLine"
+            />
+    </com.google.android.material.textfield.TextInputLayout>
+    <ImageButton
+        android:id="@+id/template_details_head_scan_qr_button"
+        android:layout_width="wrap_content"
+        android:layout_height="0dp"
+        android:background="@android:color/transparent"
+        android:contentDescription="@string/scan_qr"
+        android:minWidth="@dimen/thumb_row_height"
+        app:layout_constraintBottom_toBottomOf="@id/test_text_layout"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintTop_toTopOf="@id/test_text_layout"
+        app:srcCompat="@drawable/ic_baseline_qr_code_scanner_24"
+        app:tint="?colorPrimary"
+        />
+    <TextView
+        android:id="@+id/transaction_parameters_label"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginBottom="@dimen/text_margin"
+        android:gravity="end"
+        android:text="@string/template_transaction_parameters_label"
+        android:textAppearance="@android:style/TextAppearance.Material.Medium"
+        app:layout_constraintBottom_toTopOf="@+id/transaction_date_label"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/test_text_layout"
+        />
+    <TextView
+        android:id="@+id/transaction_date_label"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:text="@string/template_details_date_label"
+        android:textAppearance="?attr/textAppearanceListItem"
+        app:layout_constraintBottom_toTopOf="@id/year_source_label"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/transaction_parameters_label"
+        />
+    <TextView
+        android:id="@+id/year_source_label"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:text="@string/template_details_date_year_source_label"
+        android:textAlignment="center"
+        android:textAppearance="@android:style/TextAppearance.Material.Body1"
+        app:layout_constraintBottom_toTopOf="@+id/year_source"
+        app:layout_constraintEnd_toStartOf="@id/month_source_label"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/transaction_date_label"
+        />
+    <TextView
+        android:id="@+id/month_source_label"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:text="@string/month_source_label"
+        android:textAlignment="center"
+        android:textAppearance="@android:style/TextAppearance.Material.Body1"
+        app:layout_constraintBottom_toTopOf="@+id/month_source"
+        app:layout_constraintEnd_toStartOf="@id/day_source_label"
+        app:layout_constraintStart_toEndOf="@id/year_source_label"
+        app:layout_constraintTop_toBottomOf="@id/transaction_date_label"
+        />
+    <TextView
+        android:id="@+id/day_source_label"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:text="@string/template_details_date_day_source_label"
+        android:textAlignment="center"
+        android:textAppearance="@android:style/TextAppearance.Material.Body1"
+        app:layout_constraintBottom_toTopOf="@+id/day_source"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toEndOf="@id/month_source_label"
+        app:layout_constraintTop_toBottomOf="@id/transaction_date_label"
+        />
+    <TextView
+        android:id="@+id/year_source"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:text="@string/template_details_source_literal"
+        android:textAlignment="center"
+        android:textAppearance="?attr/textAppearanceListItemSecondary"
+        android:layout_marginHorizontal="@dimen/half_text_margin"
+        app:layout_constraintBottom_toTopOf="@+id/year_layout"
+        app:layout_constraintEnd_toStartOf="@id/month_source"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/day_source_label"
+        />
+    <TextView
+        android:id="@+id/month_source"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginHorizontal="@dimen/half_text_margin"
+        android:text="@string/template_details_source_literal"
+        android:textAlignment="center"
+        android:textAppearance="?attr/textAppearanceListItemSecondary"
+        app:layout_constraintBottom_toTopOf="@+id/month_layout"
+        app:layout_constraintEnd_toStartOf="@id/day_source"
+        app:layout_constraintStart_toEndOf="@id/year_source"
+        app:layout_constraintTop_toBottomOf="@id/month_source_label"
+        />
+    <TextView
+        android:id="@+id/day_source"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginHorizontal="@dimen/half_text_margin"
+        android:text="@string/template_details_source_literal"
+        android:textAlignment="center"
+        android:textAppearance="?attr/textAppearanceListItemSecondary"
+        app:layout_constraintBottom_toTopOf="@+id/day_layout"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toEndOf="@id/month_source"
+        app:layout_constraintTop_toBottomOf="@id/day_source_label"
+        />
+    <androidx.constraintlayout.widget.Barrier
+        android:id="@+id/barrier_before_date_inputs"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        app:barrierDirection="bottom"
+        app:constraint_referenced_ids="year_source,month_source,day_source"
+        />
+    <com.google.android.material.textfield.TextInputLayout
+        android:id="@+id/year_layout"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        app:layout_constraintEnd_toEndOf="@id/year_source_label"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/barrier_before_date_inputs"
+        >
+        <com.google.android.material.textfield.TextInputEditText
+            android:id="@+id/template_details_date_year"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:gravity="center_horizontal"
+            android:hint="@string/date_year_hint"
+            android:inputType="number"
+            />
+    </com.google.android.material.textfield.TextInputLayout>
+    <com.google.android.material.textfield.TextInputLayout
+        android:id="@+id/month_layout"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        app:layout_constraintEnd_toEndOf="@id/month_source_label"
+        app:layout_constraintStart_toStartOf="@id/month_source_label"
+        app:layout_constraintTop_toBottomOf="@id/barrier_before_date_inputs"
+        >
+        <com.google.android.material.textfield.TextInputEditText
+            android:id="@+id/template_details_date_month"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:gravity="center_horizontal"
+            android:hint="@string/date_month_hint"
+            android:inputType="number"
+            />
+    </com.google.android.material.textfield.TextInputLayout>
+    <com.google.android.material.textfield.TextInputLayout
+        android:id="@+id/day_layout"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginBottom="@dimen/text_margin"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="@id/day_source_label"
+        app:layout_constraintTop_toBottomOf="@id/barrier_before_date_inputs"
+        >
+        <com.google.android.material.textfield.TextInputEditText
+            android:id="@+id/template_details_date_day"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:gravity="center_horizontal"
+            android:hint="@string/date_day_hint"
+            android:inputType="number"
+            />
+    </com.google.android.material.textfield.TextInputLayout>
+    <androidx.constraintlayout.widget.Barrier
+        android:id="@+id/barrier_before_description"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="horizontal"
+        app:barrierDirection="bottom"
+        app:constraint_referenced_ids="day_layout,month_layout,year_layout"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        />
+    <TextView
+        android:id="@+id/template_transaction_description_source_label"
+        android:layout_width="0dp"
+        android:layout_height="match_parent"
+        android:layout_marginTop="@dimen/text_margin"
+        android:text="@string/transaction_description_source_label"
+        android:textAppearance="?attr/textAppearanceListItem"
+        app:layout_constraintBottom_toTopOf="@+id/template_transaction_description_source"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/barrier_before_description"
+        />
+    <TextView
+        android:id="@+id/template_transaction_description_source"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:minWidth="100dp"
+        android:textAppearance="?attr/textAppearanceListItemSecondary"
+        android:text="@string/template_details_source_literal"
+        app:layout_constraintBottom_toTopOf="@+id/transaction_description_layout"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/template_transaction_description_source_label"
+        />
+    <com.google.android.material.textfield.TextInputLayout
+        android:id="@+id/transaction_description_layout"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginBottom="@dimen/text_margin"
+        app:endIconMode="clear_text"
+        app:layout_constraintBottom_toTopOf="@+id/template_transaction_comment_source_label"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/template_transaction_description_source"
+        >
+        <com.google.android.material.textfield.TextInputEditText
+            android:id="@+id/transaction_description"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:hint="@string/template_transaction_description_hint"
+            />
+    </com.google.android.material.textfield.TextInputLayout>
+    <TextView
+        android:id="@+id/template_transaction_comment_source_label"
+        android:layout_width="0dp"
+        android:layout_height="match_parent"
+        android:text="@string/transaction_comment_source_label"
+        android:textAppearance="?attr/textAppearanceListItem"
+        app:layout_constraintBottom_toTopOf="@+id/template_transaction_comment_source"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/transaction_description_layout"
+        />
+    <TextView
+        android:id="@+id/template_transaction_comment_source"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:minWidth="100dp"
+        android:textAppearance="?attr/textAppearanceListItemSecondary"
+        android:text="@string/template_details_source_literal"
+        app:layout_constraintBottom_toTopOf="@+id/transaction_comment_layout"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/template_transaction_comment_source_label"
+        />
+    <com.google.android.material.textfield.TextInputLayout
+        android:id="@+id/transaction_comment_layout"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginBottom="@dimen/text_margin"
+        app:endIconMode="clear_text"
+        app:layout_constraintBottom_toTopOf="@id/template_is_fallback_label"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/template_transaction_comment_source"
+        >
+        <com.google.android.material.textfield.TextInputEditText
+            android:id="@+id/transaction_comment"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:hint="@string/template_transaction_comment_hint"
+            />
+    </com.google.android.material.textfield.TextInputLayout>
+    <TextView
+        android:id="@+id/template_is_fallback_label"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:text="@string/template_is_fallback_label"
+        android:textAppearance="?attr/textAppearanceListItem"
+        app:layout_constraintBottom_toTopOf="@+id/template_is_fallback_text"
+        app:layout_constraintEnd_toStartOf="@id/template_is_fallback_switch"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/transaction_comment_layout"
+        />
+    <TextView
+        android:id="@+id/template_is_fallback_text"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:textAppearance="?attr/textAppearanceListItemSecondary"
+        android:text="@string/template_is_fallback_no"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toStartOf="@id/template_is_fallback_switch"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/template_is_fallback_label"
+        />
+    <com.google.android.material.switchmaterial.SwitchMaterial
+        android:id="@+id/template_is_fallback_switch"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        app:layout_constraintBottom_toBottomOf="@id/template_is_fallback_text"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintTop_toTopOf="@id/template_is_fallback_label"
+        />
+
+
+</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
diff --git a/app/src/main/res/layout/template_list_template_item.xml b/app/src/main/res/layout/template_list_template_item.xml
new file mode 100644 (file)
index 0000000..4cade77
--- /dev/null
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Copyright © 2021 Damyan Ivanov.
+  ~ This file is part of MoLe.
+  ~ MoLe is free software: you can distribute it and/or modify it
+  ~ under the term of the GNU General Public License as published by
+  ~ the Free Software Foundation, either version 3 of the License, or
+  ~ (at your opinion), any later version.
+  ~
+  ~ MoLe is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  ~ GNU General Public License terms for details.
+  ~
+  ~ You should have received a copy of the GNU General Public License
+  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+  -->
+
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    >
+    <TextView
+        android:id="@+id/template_name"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginHorizontal="@dimen/text_margin"
+        android:gravity="center_vertical"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        android:minHeight="@dimen/thumb_row_height"
+        android:textAppearance="@android:style/TextAppearance.Material.Medium"
+        app:layout_constraintEnd_toEndOf="parent"
+        />
+</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
diff --git a/app/src/main/res/layout/templates_fallback_divider.xml b/app/src/main/res/layout/templates_fallback_divider.xml
new file mode 100644 (file)
index 0000000..bfeb0fa
--- /dev/null
@@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Copyright © 2021 Damyan Ivanov.
+  ~ This file is part of MoLe.
+  ~ MoLe is free software: you can distribute it and/or modify it
+  ~ under the term of the GNU General Public License as published by
+  ~ the Free Software Foundation, either version 3 of the License, or
+  ~ (at your opinion), any later version.
+  ~
+  ~ MoLe is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  ~ GNU General Public License terms for details.
+  ~
+  ~ You should have received a copy of the GNU General Public License
+  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+  -->
+
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    >
+    <TextView
+        android:id="@+id/label"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:background="?table_row_light_bg"
+        android:gravity="center_vertical|end"
+        android:minHeight="@dimen/thumb_row_height"
+        android:paddingHorizontal="@dimen/text_margin"
+        android:paddingVertical="@dimen/half_text_margin"
+        android:text="@string/fallback_templates_divider"
+        android:textAppearance="@android:style/TextAppearance.Material.Widget.Toolbar.Title"
+        android:textColor="?android:textColorSecondary"
+        android:textStyle="italic"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        />
+</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
diff --git a/app/src/main/res/layout/transaction_delimiter.xml b/app/src/main/res/layout/transaction_delimiter.xml
new file mode 100644 (file)
index 0000000..0b283b3
--- /dev/null
@@ -0,0 +1,65 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Copyright © 2021 Damyan Ivanov.
+  ~ This file is part of MoLe.
+  ~ MoLe is free software: you can distribute it and/or modify it
+  ~ under the term of the GNU General Public License as published by
+  ~ the Free Software Foundation, either version 3 of the License, or
+  ~ (at your opinion), any later version.
+  ~
+  ~ MoLe is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  ~ GNU General Public License terms for details.
+  ~
+  ~ You should have received a copy of the GNU General Public License
+  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+  -->
+
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/transaction_delimiter"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:layout_marginStart="8dp"
+    android:layout_marginTop="16dp"
+    android:layout_marginEnd="8dp"
+    >
+
+    <TextView
+        android:id="@+id/transaction_delimiter_month"
+        android:layout_width="wrap_content"
+        android:layout_height="match_parent"
+        android:layout_marginHorizontal="4dp"
+        android:text="---------"
+        android:textColor="?colorPrimary"
+        android:textStyle="bold"
+        app:layout_constraintEnd_toEndOf="parent"
+        tools:ignore="HardcodedText"
+        />
+
+    <TextView
+        android:id="@+id/transaction_delimiter_date"
+        android:layout_width="wrap_content"
+        android:layout_height="match_parent"
+        android:layout_marginHorizontal="4dp"
+        android:text="--.--.----"
+        android:textColor="?colorPrimary"
+        android:textStyle="bold"
+        app:layout_constraintStart_toStartOf="parent"
+        tools:ignore="HardcodedText"
+        />
+
+    <View
+        android:id="@+id/transaction_delimiter_thick"
+        android:layout_width="0dp"
+        android:layout_height="1dp"
+        android:layout_marginHorizontal="16dp"
+        android:background="?colorPrimary"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toStartOf="@id/transaction_delimiter_month"
+        app:layout_constraintStart_toEndOf="@id/transaction_delimiter_date"
+        app:layout_constraintTop_toTopOf="parent"
+        />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
index a8940553ee5b151447072283013a0a1c2007aff9..a91456ccc7c27c061cfc71a0a60b026810b29101 100644 (file)
@@ -1,7 +1,7 @@
 <?xml version="1.0" encoding="utf-8"?>
 
 <!--
-  ~ Copyright © 2020 Damyan Ivanov.
+  ~ Copyright © 2021 Damyan Ivanov.
   ~ This file is part of MoLe.
   ~ MoLe is free software: you can distribute it and/or modify it
   ~ under the term of the GNU General Public License as published by
@@ -22,7 +22,6 @@
     xmlns:tools="http://schemas.android.com/tools"
     android:layout_width="match_parent"
     android:layout_height="wrap_content"
-    android:measureAllChildren="false"
     >
 
     <com.google.android.material.card.MaterialCardView
@@ -48,7 +47,7 @@
 
             <LinearLayout
                 android:id="@+id/transaction_row_head"
-                android:layout_width="match_parent"
+                android:layout_width="0dp"
                 android:layout_height="wrap_content"
                 android:orientation="vertical"
                 app:layout_constraintEnd_toEndOf="parent"
 
             <LinearLayout
                 android:id="@+id/transaction_row_acc_amounts"
-                android:layout_width="match_parent"
+                android:layout_width="0dp"
                 android:layout_height="wrap_content"
                 android:layout_marginTop="8dp"
                 android:orientation="vertical"
-                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintEnd_toStartOf="@id/transaction_running_total"
                 app:layout_constraintStart_toStartOf="parent"
                 app:layout_constraintTop_toBottomOf="@+id/transaction_row_head"
                 >
                 <include layout="@layout/transaction_list_row_accounts_table_row" />
 
             </LinearLayout>
+            <androidx.constraintlayout.widget.Barrier
+                android:id="@+id/barrier"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                app:barrierDirection="top"
+                app:constraint_referenced_ids="transaction_row_acc_amounts,transaction_running_total"
+                />
+
+            <TextView
+                android:id="@+id/transaction_running_total"
+                style="@style/transaction_list_comment"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginStart="@dimen/half_text_margin"
+                android:gravity="bottom|end"
+                android:text="one two"
+                android:visibility="visible"
+                app:layout_constraintBottom_toBottomOf="@id/transaction_row_acc_amounts"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintStart_toEndOf="@id/transaction_row_acc_amounts"
+                app:layout_goneMarginStart="0dp"
+                />
+            <View
+                android:id="@+id/transaction_running_total_divider"
+                android:layout_width="1dp"
+                android:layout_height="0dp"
+                android:layout_marginStart="@dimen/quarter_text_margin"
+                android:background="?commentColor"
+                app:layout_constraintBottom_toBottomOf="@id/transaction_running_total"
+                app:layout_constraintStart_toEndOf="@id/transaction_row_acc_amounts"
+                app:layout_constraintTop_toBottomOf="@id/barrier"
+                app:layout_goneMarginStart="0dp"
+                />
 
         </androidx.constraintlayout.widget.ConstraintLayout>
     </com.google.android.material.card.MaterialCardView>
 
-    <androidx.constraintlayout.widget.ConstraintLayout
-        android:id="@+id/transaction_delimiter"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:layout_marginStart="8dp"
-        android:layout_marginTop="16dp"
-        android:layout_marginEnd="8dp"
-        >
-
-        <TextView
-            android:id="@+id/transaction_delimiter_month"
-            android:layout_width="wrap_content"
-            android:layout_height="match_parent"
-            android:layout_marginHorizontal="4dp"
-            android:text="---------"
-            android:textColor="?colorPrimary"
-            android:textStyle="bold"
-            app:layout_constraintEnd_toEndOf="parent"
-            tools:ignore="HardcodedText"
-            />
-
-        <TextView
-            android:id="@+id/transaction_delimiter_date"
-            android:layout_width="wrap_content"
-            android:layout_height="match_parent"
-            android:layout_marginHorizontal="4dp"
-            android:text="--.--.----"
-            android:textColor="?colorPrimary"
-            android:textStyle="bold"
-            app:layout_constraintStart_toStartOf="parent"
-            tools:ignore="HardcodedText"
-            />
-
-        <View
-            android:id="@+id/transaction_delimiter_thick"
-            android:layout_width="0dp"
-            android:layout_height="1dp"
-            android:layout_marginHorizontal="16dp"
-            android:background="?colorPrimary"
-            app:layout_constraintBottom_toBottomOf="parent"
-            app:layout_constraintEnd_toStartOf="@id/transaction_delimiter_month"
-            app:layout_constraintStart_toEndOf="@id/transaction_delimiter_date"
-            app:layout_constraintTop_toTopOf="parent"
-            />
-
-    </androidx.constraintlayout.widget.ConstraintLayout>
-    <include layout="@layout/last_update_layout" />
 
 </FrameLayout>
\ No newline at end of file
index 5dbed6054eb006f6ba5320d80f4dd990153334c6..ac2853b4405a15258b00da496b7a16ba4660112c 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?><!--
-  ~ Copyright © 2020 Damyan Ivanov.
+  ~ Copyright © 2021 Damyan Ivanov.
   ~ This file is part of MoLe.
   ~ MoLe is free software: you can distribute it and/or modify it
   ~ under the term of the GNU General Public License as published by
             android:id="@+id/transaction_list_acc_row_acc_name"
             android:layout_width="match_parent"
             android:layout_height="wrap_content"
-            android:text="another acc name"
+            android:breakStrategy="high_quality"
             android:textAlignment="viewStart"
             android:textAppearance="@android:style/TextAppearance.Material.Small"
             tools:ignore="HardcodedText"
+            android:hyphenationFrequency="full"
+            android:text="one:very:long:account:name:that:needs:to:wrap:to:more:tnan:one:line:two:would:be:nice:but:the:more:the:better:and:the:better:"
             />
 
         <TextView
@@ -65,6 +67,7 @@
         android:layout_height="wrap_content"
         android:layout_gravity="top|end"
         android:layout_marginEnd="0dp"
+        android:layout_marginStart="@dimen/half_text_margin"
         android:minWidth="60dp"
         android:text="---,--"
         android:textAlignment="viewEnd"
diff --git a/app/src/main/res/menu/account_list.xml b/app/src/main/res/menu/account_list.xml
new file mode 100644 (file)
index 0000000..5a76161
--- /dev/null
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Copyright © 2024 Damyan Ivanov.
+  ~ This file is part of MoLe.
+  ~ MoLe is free software: you can distribute it and/or modify it
+  ~ under the term of the GNU General Public License as published by
+  ~ the Free Software Foundation, either version 3 of the License, or
+  ~ (at your opinion), any later version.
+  ~
+  ~ MoLe is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  ~ GNU General Public License terms for details.
+  ~
+  ~ You should have received a copy of the GNU General Public License
+  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+  -->
+
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    >
+
+    <item
+        android:id="@+id/menu_account_list_show_zero_balances"
+        android:checkable="true"
+        android:enabled="true"
+        android:menuCategory="container"
+        android:title="@string/accounts_menu_show_zero"
+        android:titleCondensed="@string/accounts_menu_show_zero_condensed"
+        android:visible="true"
+        app:showAsAction="withText"
+        />
+</menu>
\ No newline at end of file
diff --git a/app/src/main/res/menu/account_summary.xml b/app/src/main/res/menu/account_summary.xml
deleted file mode 100644 (file)
index 28e9129..0000000
+++ /dev/null
@@ -1,47 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!--
-  ~ Copyright © 2019 Damyan Ivanov.
-  ~ This file is part of MoLe.
-  ~ MoLe is free software: you can distribute it and/or modify it
-  ~ under the term of the GNU General Public License as published by
-  ~ the Free Software Foundation, either version 3 of the License, or
-  ~ (at your opinion), any later version.
-  ~
-  ~ MoLe is distributed in the hope that it will be useful,
-  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
-  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-  ~ GNU General Public License terms for details.
-  ~
-  ~ You should have received a copy of the GNU General Public License
-  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
-  -->
-
-<menu xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:tools="http://schemas.android.com/tools"
-    tools:context="net.ktnx.mobileledger.ui.activity.MainActivity">
-
-    <item
-        android:id="@+id/menu_acc_summary_only_starred"
-        android:checkable="true"
-        android:checked="false"
-        android:title="@string/menu_acc_summary_show_only_starred_title"
-        app:actionLayout="@layout/switch_item"
-        app:showAsAction="never" />
-    <item android:id="@+id/menu_acc_summary_hide_selected"
-        android:icon="@drawable/ic_star_white_24dp"
-        android:title="@string/menu_acc_summary_hide_selected_title"
-        app:showAsAction="always"
-        android:visible="false"
-        />
-    <item android:id="@+id/menu_acc_summary_cancel_selection"
-        android:title="@string/menu_acc_summary_cancel_selection_title"
-        app:showAsAction="always"
-        android:visible="false"
-        android:icon="@drawable/ic_cancel_white_24dp" />
-    <item android:id="@+id/menu_acc_summary_confirm_selection"
-        android:title="@string/menu_acc_summary_confirm_selection_title"
-        app:showAsAction="always"
-        android:visible="false"
-        android:icon="@drawable/ic_check_white_24dp" />
-</menu>
\ No newline at end of file
index 5fcb1de9b156a5125a984dc2d1707e0237353675..53da72fbb4abbfe56562cba235a1bed9a43f2e30 100644 (file)
@@ -1,6 +1,5 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!--
-  ~ Copyright © 2019 Damyan Ivanov.
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Copyright © 2020 Damyan Ivanov.
   ~ This file is part of MoLe.
   ~ MoLe is free software: you can distribute it and/or modify it
   ~ under the term of the GNU General Public License as published by
 
     <item
         android:id="@+id/api_version_menu_auto"
-        android:title="@string/api_auto" />
+        android:title="@string/api_auto"
+        />
     <item
-        android:id="@+id/api_version_menu_post_1_14"
-        android:title="@string/api_post_1_14" />
+        android:id="@+id/api_version_menu_1_23"
+        android:title="@string/api_1_23"
+        />
     <item
-        android:id="@+id/api_version_menu_pre_1_15"
-        android:title="@string/api_pre_1_15" />
+        android:id="@+id/api_version_menu_1_19_1"
+        android:title="@string/api_1_19_1"
+        />
+    <item
+        android:id="@+id/api_version_menu_1_15"
+        android:title="@string/api_1_15"
+        />
+    <item
+        android:id="@+id/api_version_menu_1_14"
+        android:title="@string/api_1_14"
+        />
     <item
         android:id="@+id/api_version_menu_html"
-        android:title="@string/api_html" />
+        android:title="@string/api_html"
+        />
 </menu>
\ No newline at end of file
index 060750803728d9bb0905680803133d37eb07819c..f4862d8c2e7a2f3a125af1eeb21bc37458cb4b64 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?><!--
-  ~ Copyright © 2019 Damyan Ivanov.
+  ~ Copyright © 2021 Damyan Ivanov.
   ~ This file is part of MoLe.
   ~ MoLe is free software: you can distribute it and/or modify it
   ~ under the term of the GNU General Public License as published by
   -->
 
 <menu xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto">
-    <item
-        android:id="@+id/action_simulate_crash"
-        android:title="@string/crash_app_label"
-        android:titleCondensed="@string/crash_app_condensed_label"
-        android:onClick="simulateCrash"
-        android:visible="false"
-        app:showAsAction="never" />
-    <item
-        android:id="@+id/action_simulate_save"
-        android:checkable="true"
-        android:checked="false"
-        android:onClick="toggleSimulateSave"
-        android:title="@string/simulate_save_label"
-        android:titleCondensed="@string/simulate_save_condensed_label"
-        android:visible="false"
-        app:showAsAction="never" />
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    >
+    <group android:id="@+id/new_transaction_debug_menu_items">
+        <item
+            android:id="@+id/action_simulate_crash"
+            android:title="@string/crash_app_label"
+            android:titleCondensed="@string/crash_app_condensed_label"
+            app:showAsAction="never"
+            />
+        <item
+            android:id="@+id/action_simulate_save"
+            android:checkable="true"
+            android:checked="false"
+            android:title="@string/simulate_save_label"
+            android:titleCondensed="@string/simulate_save_condensed_label"
+            app:showAsAction="never"
+            />
+    </group>
 </menu>
\ No newline at end of file
index c55428e95440feff2cb8b1a4a912fd0de971cf22..b9a34687adc330e3ddf187cddf837273a350f5ae 100644 (file)
@@ -1,6 +1,5 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!--
-  ~ Copyright © 2019 Damyan Ivanov.
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Copyright © 2021 Damyan Ivanov.
   ~ This file is part of MoLe.
   ~ MoLe is free software: you can distribute it and/or modify it
   ~ under the term of the GNU General Public License as published by
   -->
 
 <menu xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto">
-    <item
-        android:id="@+id/toggle_currency"
-        android:title="@string/show_currency_input"
-        android:checkable="true"
-        android:checked="false"
-        app:showAsAction="never" />
-    <item
-        android:id="@+id/toggle_comments"
-        android:checkable="true"
-        android:title="@string/show_comments_switch"
-        app:actionLayout="@layout/switch_item"
-        app:showAsAction="never" />
-    <item
-        android:id="@+id/action_reset_new_transaction_activity"
-        android:icon="@drawable/ic_refresh_white_24dp"
-        android:title="@string/action_reset_new_transaction_activity_title"
-        app:showAsAction="never" />
-
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    >
+    <group android:id="@+id/new_transaction_fragment_menu_items">
+        <item
+            android:id="@+id/scan_qr"
+            android:icon="@drawable/ic_baseline_qr_code_scanner_24"
+            android:title="@string/scan_qr"
+            app:showAsAction="ifRoom"
+            />
+        <item
+            android:id="@+id/toggle_currency"
+            android:checkable="true"
+            android:checked="false"
+            android:title="@string/show_currency_input"
+            app:actionLayout="@layout/switch_item"
+            app:showAsAction="never"
+            />
+        <item
+            android:id="@+id/toggle_comments"
+            android:checkable="true"
+            android:title="@string/show_comments_switch"
+            app:actionLayout="@layout/switch_item"
+            app:showAsAction="never"
+            />
+        <item
+            android:id="@+id/action_reset_new_transaction_activity"
+            android:icon="@drawable/ic_refresh_white_24dp"
+            android:title="@string/action_reset_new_transaction_activity_title"
+            app:showAsAction="never"
+            />
+    </group>
 </menu>
\ No newline at end of file
diff --git a/app/src/main/res/menu/profile_list.xml b/app/src/main/res/menu/profile_list.xml
deleted file mode 100644 (file)
index bb968ca..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
-  ~ Copyright © 2019 Damyan Ivanov.
-  ~ This file is part of MoLe.
-  ~ MoLe is free software: you can distribute it and/or modify it
-  ~ under the term of the GNU General Public License as published by
-  ~ the Free Software Foundation, either version 3 of the License, or
-  ~ (at your opinion), any later version.
-  ~
-  ~ MoLe is distributed in the hope that it will be useful,
-  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
-  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-  ~ GNU General Public License terms for details.
-  ~
-  ~ You should have received a copy of the GNU General Public License
-  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
-  -->
-
-<menu xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto">
-
-    <item
-        android:id="@+id/menu_add_profile"
-        android:icon="@drawable/ic_add_circle_white_24dp"
-        android:title="@string/create_profile_label"
-        app:showAsAction="ifRoom" />
-</menu>
\ No newline at end of file
diff --git a/app/src/main/res/menu/template_details_menu.xml b/app/src/main/res/menu/template_details_menu.xml
new file mode 100644 (file)
index 0000000..55a22a8
--- /dev/null
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Copyright © 2021 Damyan Ivanov.
+  ~ This file is part of MoLe.
+  ~ MoLe is free software: you can distribute it and/or modify it
+  ~ under the term of the GNU General Public License as published by
+  ~ the Free Software Foundation, either version 3 of the License, or
+  ~ (at your opinion), any later version.
+  ~
+  ~ MoLe is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  ~ GNU General Public License terms for details.
+  ~
+  ~ You should have received a copy of the GNU General Public License
+  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+  -->
+
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    >
+
+    <item
+        android:id="@+id/delete_template"
+        android:icon="@drawable/ic_delete_white_24dp"
+        android:title="@string/Remove"
+        app:showAsAction="always"
+        />
+</menu>
\ No newline at end of file
diff --git a/app/src/main/res/menu/template_list_menu.xml b/app/src/main/res/menu/template_list_menu.xml
new file mode 100644 (file)
index 0000000..780d825
--- /dev/null
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Copyright © 2021 Damyan Ivanov.
+  ~ This file is part of MoLe.
+  ~ MoLe is free software: you can distribute it and/or modify it
+  ~ under the term of the GNU General Public License as published by
+  ~ the Free Software Foundation, either version 3 of the License, or
+  ~ (at your opinion), any later version.
+  ~
+  ~ MoLe is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  ~ GNU General Public License terms for details.
+  ~
+  ~ You should have received a copy of the GNU General Public License
+  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+  -->
+
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    >
+
+    <item
+        android:icon="@drawable/ic_baseline_help_24_white"
+        android:title="@string/help_menu_item_title"
+        android:id="@+id/menu_item_template_list_help"
+        app:showAsAction="ifRoom"
+        />
+</menu>
\ No newline at end of file
index 734cd71e61680556f55c13f8a60b84c3f8ed00cb..1178f88cac9ca63a6bf944f4b02ad0b87f6948d3 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?><!--
-  ~ Copyright © 2020 Damyan Ivanov.
+  ~ Copyright © 2021 Damyan Ivanov.
   ~ This file is part of MoLe.
   ~ MoLe is free software: you can distribute it and/or modify it
   ~ under the term of the GNU General Public License as published by
@@ -23,7 +23,7 @@
 
     <fragment
         android:id="@+id/newTransactionFragment"
-        android:name="net.ktnx.mobileledger.ui.activity.NewTransactionFragment"
+        android:name="net.ktnx.mobileledger.ui.new_transaction.NewTransactionFragment"
         android:label="NewTransactionFragment"
         >
         <action
diff --git a/app/src/main/res/navigation/template_list_navigation.xml b/app/src/main/res/navigation/template_list_navigation.xml
new file mode 100644 (file)
index 0000000..cbd32a1
--- /dev/null
@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Copyright © 2021 Damyan Ivanov.
+  ~ This file is part of MoLe.
+  ~ MoLe is free software: you can distribute it and/or modify it
+  ~ under the term of the GNU General Public License as published by
+  ~ the Free Software Foundation, either version 3 of the License, or
+  ~ (at your opinion), any later version.
+  ~
+  ~ MoLe is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  ~ GNU General Public License terms for details.
+  ~
+  ~ You should have received a copy of the GNU General Public License
+  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+  -->
+
+<navigation xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/template_list_navigation"
+    app:startDestination="@id/templateListFragment"
+    >
+
+    <fragment
+        android:id="@+id/templateListFragment"
+        android:name="net.ktnx.mobileledger.ui.templates.TemplateListFragment"
+        android:label="TemplateListFragment"
+        android:tag="templateListFragment"
+        >
+        <action
+            android:id="@+id/action_templateListFragment_to_templateDetailsFragment"
+            app:destination="@id/templateDetailsFragment"
+            app:enterAnim="@anim/slide_in_left"
+            app:exitAnim="@anim/slide_out_left"
+            />
+    </fragment>
+    <fragment
+        android:id="@+id/templateDetailsFragment"
+        android:name="net.ktnx.mobileledger.ui.templates.TemplateDetailsFragment"
+        android:label="pattern_details_fragment"
+        android:tag="patternDetailsFragment"
+        tools:layout="@layout/template_details_fragment"
+        >
+        <action
+            android:id="@+id/action_templateDetailsFragment_to_templateListFragment"
+            app:destination="@id/templateListFragment"
+            />
+    </fragment>
+</navigation>
\ No newline at end of file
diff --git a/app/src/main/res/raw/create_db.sql b/app/src/main/res/raw/create_db.sql
deleted file mode 100644 (file)
index 2126aed..0000000
+++ /dev/null
@@ -1,31 +0,0 @@
--- Copyright © 2020 Damyan Ivanov.
--- This file is part of MoLe.
--- MoLe is free software: you can distribute it and/or modify it
--- under the term of the GNU General Public License as published by
--- the Free Software Foundation, either version 3 of the License, or
--- (at your opinion), any later version.
---
--- MoLe is distributed in the hope that it will be useful,
--- but WITHOUT ANY WARRANTY; without even the implied warranty of
--- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
--- GNU General Public License terms for details.
---
--- You should have received a copy of the GNU General Public License
--- along with MoLe. If not, see <https://www.gnu.org/licenses/>.
-create table profiles(uuid varchar not null primary key, name not null, url not null, use_authentication boolean not null, auth_user varchar, auth_password varchar, order_no integer, permit_posting boolean default 0, theme integer default -1, preferred_accounts_filter varchar, future_dates integer, api_version integer, show_commodity_by_default boolean default 0, default_commodity varchar, show_comments_by_default boolean default 1, detected_version_pre_1_19 boolean, detected_version_major integer, detected_version_minor integer);
-create table accounts(profile varchar not null, name varchar not null, name_upper varchar not null, level integer not null, parent_name varchar, expanded default 1, amounts_expanded boolean default 0, generation integer default 0);
-create unique index un_accounts on accounts(profile, name);
-create table options(profile varchar not null, name varchar not null, value varchar);
-create unique index un_options on options(profile,name);
-create table account_values(profile varchar not null, account varchar not null, currency varchar not null default '', value decimal not null, generation integer default 0 );
-create unique index un_account_values on account_values(profile,account,currency);
-create table description_history(description varchar not null primary key, description_upper varchar, generation integer default 0);
-create unique index un_description_history on description_history(description_upper);
-create table transactions(profile varchar not null, id integer not null, data_hash varchar not null, year integer not null, month integer not null, day integer not null, description varchar not null, comment varchar, generation integer default 0);
-create unique index un_transactions_id on transactions(profile,id);
-create unique index un_transactions_data_hash on transactions(profile,data_hash);
-create index idx_transaction_description on transactions(description);
-create table transaction_accounts(profile varchar not null, transaction_id integer not null, order_no integer not null, account_name varchar not null, currency varchar not null default '', amount decimal not null, comment varchar, generation integer default 0, constraint fk_transaction_accounts_acc foreign key(profile,account_name) references accounts(profile,name), constraint fk_transaction_accounts_trn foreign key(profile, transaction_id) references transactions(profile,id));
-create unique index un_transaction_accounts_order on transaction_accounts(profile, transaction_id, order_no);
-create table currencies(id integer not null primary key, name varchar not null, position varchar not null, has_gap boolean not null);
--- updated to revision 39
\ No newline at end of file
diff --git a/app/src/main/res/raw/db_17.sql b/app/src/main/res/raw/db_17.sql
new file mode 100644 (file)
index 0000000..2da2ae3
--- /dev/null
@@ -0,0 +1,21 @@
+-- Copyright © 2019 Damyan Ivanov.
+-- This file is part of MoLe.
+-- MoLe is free software: you can distribute it and/or modify it
+-- under the term of the GNU General Public License as published by
+-- the Free Software Foundation, either version 3 of the License, or
+-- (at your opinion), any later version.
+--
+-- MoLe is distributed in the hope that it will be useful,
+-- but WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+-- GNU General Public License terms for details.
+--
+-- You should have received a copy of the GNU General Public License
+-- along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+
+BEGIN TRANSACTION;
+
+alter table profiles add permit_posting boolean default 0;
+update profiles set permit_posting = 1;
+
+COMMIT TRANSACTION;
\ No newline at end of file
diff --git a/app/src/main/res/raw/db_18.sql b/app/src/main/res/raw/db_18.sql
new file mode 100644 (file)
index 0000000..abe720b
--- /dev/null
@@ -0,0 +1,21 @@
+-- Copyright © 2019 Damyan Ivanov.
+-- This file is part of MoLe.
+-- MoLe is free software: you can distribute it and/or modify it
+-- under the term of the GNU General Public License as published by
+-- the Free Software Foundation, either version 3 of the License, or
+-- (at your opinion), any later version.
+--
+-- MoLe is distributed in the hope that it will be useful,
+-- but WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+-- GNU General Public License terms for details.
+--
+-- You should have received a copy of the GNU General Public License
+-- along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+
+BEGIN TRANSACTION;
+
+alter table profiles add theme integer default -1;
+update profiles set theme = -1;
+
+COMMIT TRANSACTION;
\ No newline at end of file
diff --git a/app/src/main/res/raw/db_19.sql b/app/src/main/res/raw/db_19.sql
new file mode 100644 (file)
index 0000000..d278070
--- /dev/null
@@ -0,0 +1,21 @@
+-- Copyright © 2019 Damyan Ivanov.
+-- This file is part of MoLe.
+-- MoLe is free software: you can distribute it and/or modify it
+-- under the term of the GNU General Public License as published by
+-- the Free Software Foundation, either version 3 of the License, or
+-- (at your opinion), any later version.
+--
+-- MoLe is distributed in the hope that it will be useful,
+-- but WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+-- GNU General Public License terms for details.
+--
+-- You should have received a copy of the GNU General Public License
+-- along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+
+BEGIN TRANSACTION;
+
+alter table accounts add expanded default 1;
+update accounts set expanded = 1;
+
+COMMIT TRANSACTION;
\ No newline at end of file
diff --git a/app/src/main/res/raw/db_20.sql b/app/src/main/res/raw/db_20.sql
new file mode 100644 (file)
index 0000000..7510b7d
--- /dev/null
@@ -0,0 +1,23 @@
+-- Copyright © 2019 Damyan Ivanov.
+-- This file is part of MoLe.
+-- MoLe is free software: you can distribute it and/or modify it
+-- under the term of the GNU General Public License as published by
+-- the Free Software Foundation, either version 3 of the License, or
+-- (at your opinion), any later version.
+--
+-- MoLe is distributed in the hope that it will be useful,
+-- but WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+-- GNU General Public License terms for details.
+--
+-- You should have received a copy of the GNU General Public License
+-- along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+
+BEGIN TRANSACTION;
+
+delete from accounts where not exists (select 1 from profiles where uuid = profile);
+delete from account_values where not exists (select 1 from profiles where uuid = profile);
+delete from transactions where not exists (select 1 from profiles where uuid = profile);
+delete from transaction_accounts where not exists (select 1 from profiles where uuid = profile);
+
+COMMIT TRANSACTION;
\ No newline at end of file
diff --git a/app/src/main/res/raw/db_20_22.sql b/app/src/main/res/raw/db_20_22.sql
new file mode 100644 (file)
index 0000000..72301de
--- /dev/null
@@ -0,0 +1,19 @@
+-- Copyright © 2021 Damyan Ivanov.
+-- This file is part of MoLe.
+-- MoLe is free software: you can distribute it and/or modify it
+-- under the term of the GNU General Public License as published by
+-- the Free Software Foundation, either version 3 of the License, or
+-- (at your opinion), any later version.
+--
+-- MoLe is distributed in the hope that it will be useful,
+-- but WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+-- GNU General Public License terms for details.
+--
+-- You should have received a copy of the GNU General Public License
+-- along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+
+-- migrate from revision 20 to revision 22
+
+alter table accounts add amounts_expanded boolean default 0;
+alter table profiles add preferred_accounts_filter varchar;
\ No newline at end of file
diff --git a/app/src/main/res/raw/db_22_30.sql b/app/src/main/res/raw/db_22_30.sql
new file mode 100644 (file)
index 0000000..e1760cc
--- /dev/null
@@ -0,0 +1,37 @@
+-- Copyright © 2021 Damyan Ivanov.
+-- This file is part of MoLe.
+-- MoLe is free software: you can distribute it and/or modify it
+-- under the term of the GNU General Public License as published by
+-- the Free Software Foundation, either version 3 of the License, or
+-- (at your opinion), any later version.
+--
+-- MoLe is distributed in the hope that it will be useful,
+-- but WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+-- GNU General Public License terms for details.
+--
+-- You should have received a copy of the GNU General Public License
+-- along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+
+-- migrate from revision 22 to revision 30
+
+-- 23, 24, 27, 28
+alter table profiles
+add future_dates integer,
+add api_version integer,
+add show_commodity_by_default boolean default 0,
+add default_commodity varchar;
+
+-- 25
+create table currencies(id integer not null primary key, name varchar not null, position varchar not null, has_gap boolean not null);
+
+-- 26
+alter table transaction_accounts add comment varchar;
+
+-- 29
+create index idx_transaction_description on transactions(description);
+
+-- 30
+delete from options
+where profile <> '-'
+  and not exists (select 1 from profiles p where p.uuid=options.profile);
\ No newline at end of file
diff --git a/app/src/main/res/raw/db_30_32.sql b/app/src/main/res/raw/db_30_32.sql
new file mode 100644 (file)
index 0000000..61ae734
--- /dev/null
@@ -0,0 +1,22 @@
+-- Copyright © 2021 Damyan Ivanov.
+-- This file is part of MoLe.
+-- MoLe is free software: you can distribute it and/or modify it
+-- under the term of the GNU General Public License as published by
+-- the Free Software Foundation, either version 3 of the License, or
+-- (at your opinion), any later version.
+--
+-- MoLe is distributed in the hope that it will be useful,
+-- but WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+-- GNU General Public License terms for details.
+--
+-- You should have received a copy of the GNU General Public License
+-- along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+
+-- migrate from revision 30 to revision 32
+
+-- 31
+alter table profiles add show_comments_by_default boolean default 0;
+
+-- 32
+update profiles set show_comments_by_default = 1;
\ No newline at end of file
diff --git a/app/src/main/res/raw/db_32_34.sql b/app/src/main/res/raw/db_32_34.sql
new file mode 100644 (file)
index 0000000..baa1ab1
--- /dev/null
@@ -0,0 +1,46 @@
+-- Copyright © 2021 Damyan Ivanov.
+-- This file is part of MoLe.
+-- MoLe is free software: you can distribute it and/or modify it
+-- under the term of the GNU General Public License as published by
+-- the Free Software Foundation, either version 3 of the License, or
+-- (at your opinion), any later version.
+--
+-- MoLe is distributed in the hope that it will be useful,
+-- but WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+-- GNU General Public License terms for details.
+--
+-- You should have received a copy of the GNU General Public License
+-- along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+
+-- migrate from revision 32 to revision 34
+
+-- 33 (merged below)
+-- alter table transactions add comment varchar;
+
+-- 34
+alter table transactions add year integer not null default 0;
+alter table transactions add month integer not null default 0;
+alter table transactions add day integer not null default 0;
+alter table transactions add tmp_md varchar;
+update transactions set year= cast(substr(date,  1,instr(date,  '/')-1) as integer);
+update transactions set tmp_md=    substr(date,    instr(date,  '/')+1);
+update transactions set month=cast(substr(tmp_md,1,instr(tmp_md,'/')-1) as integer);
+update transactions set day=  cast(substr(tmp_md,  instr(tmp_md,'/')+1) as integer);
+-- alter table transactions drop date
+create table transactions_2(
+    profile varchar not null,
+    id integer not null,
+    data_hash varchar not null,
+    year integer not null,
+    month integer not null,
+    day integer not null,
+    description varchar not null,
+    comment varchar,
+    keep boolean not null default 0);
+insert into transactions_2(profile, id, data_hash, year, month, day, description, comment, keep)
+select profile, id, data_hash, year, month, day, description, null, keep from transactions;
+
+drop table transactions;
+
+alter table transactions_2 rename to transactions;
diff --git a/app/src/main/res/raw/db_34_40.sql b/app/src/main/res/raw/db_34_40.sql
new file mode 100644 (file)
index 0000000..c386749
--- /dev/null
@@ -0,0 +1,70 @@
+-- Copyright © 2021 Damyan Ivanov.
+-- This file is part of MoLe.
+-- MoLe is free software: you can distribute it and/or modify it
+-- under the term of the GNU General Public License as published by
+-- the Free Software Foundation, either version 3 of the License, or
+-- (at your opinion), any later version.
+--
+-- MoLe is distributed in the hope that it will be useful,
+-- but WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+-- GNU General Public License terms for details.
+--
+-- You should have received a copy of the GNU General Public License
+-- along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+
+-- migrate from revision 34 to revision 40
+
+-- 35
+create table accounts_new(
+    profile varchar not null,
+    name varchar not null,
+    name_upper varchar not null,
+    keep boolean not null default 0,
+    level integer not null,
+    parent_name varchar,
+    expanded default 1,
+    amounts_expanded boolean default 0,
+    generation integer default 0);
+
+insert into accounts_new(
+    profile, name, name_upper, keep, level, parent_name, expanded, amounts_expanded)
+select profile, name, name_upper, keep, level, parent_name, expanded, amounts_expanded
+from accounts;
+
+drop table accounts;
+
+alter table accounts_new rename to accounts;
+
+-- 36
+-- merged in 35 --alter table accounts add generation integer default 0;
+
+alter table account_values add generation integer default 0;
+
+alter table transactions add generation integer default 0;
+
+alter table transaction_accounts
+add generation integer default 0,
+add order_no integer not null default 0;
+
+-- 37
+update transaction_accounts set order_no = rowid;
+
+-- 40
+delete from transaction_accounts where not exists (select 1 from accounts a where a.profile=transaction_accounts.profile and a.name=transaction_accounts.account_name);
+delete from transaction_accounts where not exists (select 1 from transactions t where t.profile=transaction_accounts.profile and t.id=transaction_accounts.transaction_id);
+
+-- 38
+CREATE TABLE transaction_accounts_new(profile varchar not null, transaction_id integer not null, account_name varchar not null, currency varchar not null default '', amount decimal not null, comment varchar, generation integer default 0, order_no integer not null default 0, constraint fk_transaction_accounts_acc foreign key(profile,account_name) references accounts(profile,name), constraint fk_transaction_accounts_trn foreign key(profile, transaction_id) references transactions(profile,id));
+insert into transaction_accounts_new(profile, transaction_id, account_name, currency, amount, comment, generation, order_no) select profile, transaction_id, account_name, currency, amount, comment, generation, order_no from transaction_accounts;
+drop table transaction_accounts;
+alter table transaction_accounts_new rename to transaction_accounts;
+create unique index un_transaction_accounts_order on transaction_accounts(profile, transaction_id, order_no);
+
+-- 39
+create table description_history_new(description varchar not null primary key, description_upper varchar, generation integer default 0);
+insert into description_history_new(description, description_upper) select description, description_upper from description_history;
+drop table description_history;
+alter table description_history_new rename to description_history;
+create unique index un_description_history on description_history(description_upper);
+
diff --git a/app/src/main/res/raw/db_41.sql b/app/src/main/res/raw/db_41.sql
new file mode 100644 (file)
index 0000000..1c9936d
--- /dev/null
@@ -0,0 +1,22 @@
+-- Copyright © 2020 Damyan Ivanov.
+-- This file is part of MoLe.
+-- MoLe is free software: you can distribute it and/or modify it
+-- under the term of the GNU General Public License as published by
+-- the Free Software Foundation, either version 3 of the License, or
+-- (at your opinion), any later version.
+--
+-- MoLe is distributed in the hope that it will be useful,
+-- but WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+-- GNU General Public License terms for details.
+--
+-- You should have received a copy of the GNU General Public License
+-- along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+
+BEGIN TRANSACTION;
+
+alter table profiles add detected_version_pre_1_19 boolean;
+alter table profiles add detected_version_major integer;
+alter table profiles add detected_version_minor integer;
+
+COMMIT TRANSACTION;
\ No newline at end of file
diff --git a/app/src/main/res/raw/db_41_58.sql b/app/src/main/res/raw/db_41_58.sql
new file mode 100644 (file)
index 0000000..b9b40b2
--- /dev/null
@@ -0,0 +1,209 @@
+-- Copyright © 2021 Damyan Ivanov.
+-- This file is part of MoLe.
+-- MoLe is free software: you can distribute it and/or modify it
+-- under the term of the GNU General Public License as published by
+-- the Free Software Foundation, either version 3 of the License, or
+-- (at your opinion), any later version.
+--
+-- MoLe is distributed in the hope that it will be useful,
+-- but WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+-- GNU General Public License terms for details.
+--
+-- You should have received a copy of the GNU General Public License
+-- along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+
+-- migrate from revision 41 to revision 58
+
+-- profiles
+create table profiles_new(
+ uuid text not null,
+ name text not null,
+ url text not null,
+ use_authentication integer not null,
+ auth_user text,
+ auth_password text,
+ order_no integer not null,
+ permit_posting integer not null default 0,
+ theme integer not null default -1,
+ preferred_accounts_filter varchar,
+ future_dates integer not null,
+ api_version integer not null,
+ show_commodity_by_default integer not null default 0,
+ default_commodity text,
+ show_comments_by_default integer not null default 1,
+ detected_version_pre_1_19 integer not null,
+ detected_version_major integer not null,
+ detected_version_minor integer not null,
+ primary key(uuid));
+
+insert into profiles_new(
+ uuid, name, url, use_authentication, auth_user, auth_password, order_no,
+ permit_posting, theme, preferred_accounts_filter, future_dates, api_version,
+ show_commodity_by_default, default_commodity, show_comments_by_default,
+ detected_version_pre_1_19, detected_version_major, detected_version_minor)
+select uuid, name, url, use_authentication, auth_user, auth_password, order_no,
+ permit_posting, theme, preferred_accounts_filter, coalesce(future_dates,-1),
+ coalesce(api_version,0),
+ show_commodity_by_default, default_commodity, show_comments_by_default,
+ coalesce(detected_version_pre_1_19,0), coalesce(detected_version_major,0),
+ coalesce(detected_version_minor,0)
+from profiles;
+
+-- options
+create table options_new(profile varchar not null, name varchar not null, value varchar, primary key(profile, name));
+
+insert into options_new(profile, name, value)
+select profile, name, value from options;
+
+-- accounts
+create table accounts_new(
+    profile varchar not null,
+    name varchar not null,
+    name_upper varchar not null,
+    level integer not null,
+    parent_name varchar,
+    expanded integer not null default 1,
+    amounts_expanded integer not null default 0,
+    generation integer not null default 0,
+    primary key(profile, name));
+
+insert into accounts_new(profile, name, name_upper, level, parent_name,
+    expanded, amounts_expanded, generation)
+select profile, name, name_upper, level, parent_name, expanded,
+    amounts_expanded, generation from accounts;
+
+-- account_values
+create table account_values_new(
+    profile varchar not null,
+    account varchar not null,
+    currency varchar not null default '',
+    value real not null,
+    generation integer not null default 0,
+    primary key(profile, account, currency));
+
+insert into account_values_new(
+    profile, account, currency, value, generation)
+select profile, account, currency, value, generation
+from account_values;
+
+-- description_history
+create table description_history_new(
+    description varchar collate NOCASE not null,
+    description_upper varchar not null,
+    generation integer not null default 0,
+    primary key(description));
+
+insert into description_history_new(description, description_upper, generation)
+select description, description_upper, generation from description_history;
+
+-- transactions
+create table transactions_new(
+    profile varchar not null,
+    id integer not null,
+    data_hash varchar not null,
+    year integer not null,
+    month integer not null,
+    day integer not null,
+    description varchar collate NOCASE not null,
+    comment varchar,
+    generation integer not null default 0,
+    primary key(profile,id));
+
+insert into transactions_new(profile, id, data_hash, year, month, day, description,
+    comment, generation)
+select profile, id, data_hash, year, month, day, description,
+       comment, generation
+from transactions;
+
+-- transaction_accounts
+create table transaction_accounts_new(
+    profile varchar not null,
+    transaction_id integer not null,
+    order_no integer not null,
+    account_name varchar not null,
+    currency varchar not null default '',
+    amount real not null,
+    comment varchar,
+    generation integer not null default 0,
+    primary key(profile, transaction_id, order_no),
+    foreign key (profile,account_name) references accounts(profile,name)
+      on delete cascade on update restrict,
+    foreign key(profile, transaction_id) references transactions(profile,id)
+      on delete cascade on update restrict);
+
+insert into transaction_accounts_new(profile, transaction_id, order_no, account_name,
+    currency, amount, comment, generation)
+select profile, transaction_id, order_no, account_name,
+       currency, amount, comment, generation
+from transaction_accounts;
+
+--currencies
+create table currencies_new(id integer not null primary key, name varchar not null,
+    position varchar not null, has_gap integer not null);
+
+insert into currencies_new(id, name, position, has_gap)
+select id, name, position, has_gap
+from currencies;
+
+
+-- drop originals
+drop table transaction_accounts;
+drop table transactions;
+drop table account_values;
+drop table accounts;
+drop table description_history;
+drop table profiles;
+drop table options;
+drop table currencies;
+
+-- rename new
+alter table options_new              rename to options;
+alter table profiles_new             rename to profiles;
+alter table accounts_new             rename to accounts;
+alter table account_values_new       rename to account_values;
+alter table description_history_new  rename to description_history;
+alter table transactions_new         rename to transactions;
+alter table transaction_accounts_new rename to transaction_accounts;
+alter table currencies_new           rename to currencies;
+
+-- indices
+create        index fk_tran_acc_prof_acc        on transaction_accounts(profile, account_name);
+create unique index un_transactions_data_hash   on transactions(profile,data_hash);
+create        index idx_transaction_description on transactions(description);
+
+
+-- new tables
+CREATE TABLE templates (
+    id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
+    name TEXT NOT NULL,
+    regular_expression TEXT NOT NULL,
+    test_text TEXT,
+    transaction_description TEXT,
+    transaction_description_match_group INTEGER,
+    transaction_comment TEXT,
+    transaction_comment_match_group INTEGER,
+    date_year INTEGER,
+    date_year_match_group INTEGER,
+    date_month INTEGER,
+    date_month_match_group INTEGER,
+    date_day INTEGER,
+    date_day_match_group INTEGER,
+    is_fallback INTEGER NOT NULL DEFAULT 0);
+CREATE TABLE template_accounts(
+    id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
+    template_id INTEGER NOT NULL,
+    acc TEXT,
+    position INTEGER NOT NULL,
+    acc_match_group INTEGER,
+    currency INTEGER,
+    currency_match_group INTEGER,
+    amount REAL,
+    amount_match_group INTEGER,
+    comment TEXT,
+    comment_match_group INTEGER,
+    negate_amount INTEGER,
+    FOREIGN KEY(template_id) REFERENCES templates(id) ON UPDATE RESTRICT ON DELETE CASCADE,
+    FOREIGN KEY(currency) REFERENCES currencies(id) ON UPDATE RESTRICT ON DELETE RESTRICT);
+create index fk_template_accounts_template on template_accounts(template_id);
+create index fk_template_accounts_currency on template_accounts(currency);
diff --git a/app/src/main/res/raw/db_59.sql b/app/src/main/res/raw/db_59.sql
new file mode 100644 (file)
index 0000000..effa07c
--- /dev/null
@@ -0,0 +1,184 @@
+-- Copyright © 2021 Damyan Ivanov.
+-- This file is part of MoLe.
+-- MoLe is free software: you can distribute it and/or modify it
+-- under the term of the GNU General Public License as published by
+-- the Free Software Foundation, either version 3 of the License, or
+-- (at your opinion), any later version.
+--
+-- MoLe is distributed in the hope that it will be useful,
+-- but WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+-- GNU General Public License terms for details.
+--
+-- You should have received a copy of the GNU General Public License
+-- along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+
+-- migrate from revision 58 to revision 59
+
+-- pragmas need to be outside of transaction control
+-- foreign_keys is needed so that foreign key constraints are redirected
+
+commit transaction;
+pragma foreign_keys = on;
+
+begin transaction;
+
+-- profiles
+CREATE TABLE profiles_new (
+id INTEGER NOT NULL PRIMARY KEY,
+deprecated_uuid text,
+name TEXT NOT NULL,
+url TEXT NOT NULL,
+use_authentication INTEGER NOT NULL,
+auth_user TEXT,
+auth_password TEXT,
+order_no INTEGER NOT NULL,
+permit_posting INTEGER NOT NULL,
+theme INTEGER NOT NULL DEFAULT -1,
+preferred_accounts_filter TEXT,
+future_dates INTEGER NOT NULL,
+api_version INTEGER NOT NULL,
+show_commodity_by_default INTEGER NOT NULL,
+default_commodity TEXT,
+show_comments_by_default INTEGER NOT NULL DEFAULT 1,
+detected_version_pre_1_19 INTEGER NOT NULL,
+detected_version_major INTEGER NOT NULL,
+detected_version_minor INTEGER NOT NULL);
+
+insert into profiles_new(
+       deprecated_uuid, name, url, use_authentication, auth_user, auth_password,
+       order_no, permit_posting, theme, preferred_accounts_filter, future_dates, api_version,
+       show_commodity_by_default, default_commodity, show_comments_by_default, detected_version_pre_1_19,
+       detected_version_major, detected_version_minor)
+select uuid, name, url, use_authentication, auth_user, auth_password,
+       order_no, permit_posting, theme, preferred_accounts_filter, future_dates, api_version,
+       show_commodity_by_default, default_commodity, show_comments_by_default, detected_version_pre_1_19,
+       detected_version_major, detected_version_minor
+from profiles;
+
+-- accounts
+create table accounts_new(
+id integer primary key not null,
+profile_id integer not null references profiles_new(id) on delete cascade on update restrict,
+level INTEGER NOT NULL,
+name TEXT NOT NULL,
+name_upper TEXT NOT NULL,
+parent_name TEXT,
+expanded INTEGER NOT NULL DEFAULT 1,
+amounts_expanded INTEGER NOT NULL DEFAULT 0,
+generation INTEGER NOT NULL DEFAULT 0);
+
+insert into accounts_new(profile_id, level, name, name_upper, parent_name, expanded, amounts_expanded, generation)
+select p.id, a.level, a.name, a.name_upper, a.parent_name, a.expanded, a.amounts_expanded, a.generation
+from profiles_new p
+join accounts a on a.profile=p.deprecated_uuid;
+
+-- options
+create table options_new(
+name text not null,
+profile_id integer not null,
+value text,
+primary key(profile_id,name));
+
+insert into options_new(name, profile_id, value)
+select o.name, p.id, o.value
+from options o
+join profiles_new p on p.deprecated_uuid = o.profile;
+
+insert into options_new(name, profile_id, value)
+select o.name, 0, o.value
+from options o
+where o.profile='-';
+
+update options_new
+set name='profile_id'
+  , value=(select p.id from profiles_new p where p.deprecated_uuid=value)
+where name='profile_uuid';
+
+update options_new
+set name='profile_id'
+  , value=(select p.id from profiles_new p where p.deprecated_uuid=options_new.value)
+where name='profile_uuid';
+
+-- account_values
+create table account_values_new(
+id integer not null primary key,
+account_id integer not null references accounts_new(id) on delete cascade on update restrict,
+currency text not null default '',
+value real not null,
+generation integer not null default 0);
+
+insert into account_values_new(account_id, currency, value, generation)
+select a.id, av.currency, av.value, av.generation
+from account_values av
+join profiles_new p on p.deprecated_uuid=av.profile
+join accounts_new a on a.profile_id = p.id and a.name = av.account;
+
+-- transactions
+create table transactions_new(
+id integer not null primary key,
+profile_id integer not null references profiles_new(id) on delete cascade on update restrict,
+ledger_id integer not null,
+description text not null,
+year integer not null,
+month integer not null,
+day integer not null,
+comment text,
+data_hash text not null,
+generation integer not null);
+
+insert into transactions_new(profile_id, ledger_id, description, year, month, day, comment, data_hash, generation)
+select p.id, t.id, t.description, t.year, t.month, t.day, t.comment, t.data_hash, t.generation
+from transactions t
+join profiles_new p on p.deprecated_uuid = t.profile;
+
+-- transaction_accounts
+create table transaction_accounts_new(
+    id integer not null primary key,
+    transaction_id integer not null references transactions_new(id) on delete cascade on update restrict,
+    order_no integer not null,
+    account_name text not null,
+    currency text not null default '',
+    amount real not null,
+    comment text,
+    generation integer not null default 0);
+
+insert into transaction_accounts_new(transaction_id, order_no, account_name,
+    currency, amount, comment, generation)
+select t.id, ta.order_no, ta.account_name, ta.currency, ta.amount, ta.comment, ta.generation
+from transaction_accounts ta
+join profiles_new p on ta.profile=p.deprecated_uuid
+join transactions_new t on ta.transaction_id = t.ledger_id and t.profile_id=p.id;
+
+-- table drop/rename
+drop table options;
+alter table options_new rename to options;
+
+drop table account_values;
+alter table account_values_new rename to account_values;
+
+drop table transaction_accounts;
+alter table transaction_accounts_new rename to transaction_accounts;
+
+drop table transactions;
+alter table transactions_new rename to transactions;
+
+drop table accounts;
+alter table accounts_new rename to accounts;
+
+drop table profiles;
+alter table profiles_new rename to profiles;
+
+-- indices
+create index fk_account_profile on accounts(profile_id);
+create unique index un_account_name on accounts(profile_id, name);
+
+create index fk_account_value_acc on account_values(account_id);
+create unique index un_account_values on account_values(account_id, currency);
+
+create index idx_transaction_description on transactions(description);
+create unique index un_transactions_ledger_id on transactions(profile_id, ledger_id);
+create index fk_transaction_profile on transactions(profile_id);
+
+create unique index un_transaction_accounts on transaction_accounts(transaction_id, order_no);
+create index fk_trans_acc_trans on transaction_accounts(transaction_id);
\ No newline at end of file
diff --git a/app/src/main/res/raw/db_60.sql b/app/src/main/res/raw/db_60.sql
new file mode 100644 (file)
index 0000000..bd94908
--- /dev/null
@@ -0,0 +1,27 @@
+-- Copyright © 2021 Damyan Ivanov.
+-- This file is part of MoLe.
+-- MoLe is free software: you can distribute it and/or modify it
+-- under the term of the GNU General Public License as published by
+-- the Free Software Foundation, either version 3 of the License, or
+-- (at your opinion), any later version.
+--
+-- MoLe is distributed in the hope that it will be useful,
+-- but WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+-- GNU General Public License terms for details.
+--
+-- You should have received a copy of the GNU General Public License
+-- along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+
+-- migrate from revision 58 to revision 59
+
+-- pragmas need to be outside of transaction control
+-- foreign_keys is needed so that foreign key constraints are redirected
+
+commit transaction;
+pragma foreign_keys = on;
+
+begin transaction;
+
+-- drop description_history, not used any more
+drop table description_history;
diff --git a/app/src/main/res/raw/db_61.sql b/app/src/main/res/raw/db_61.sql
new file mode 100644 (file)
index 0000000..4b9dd69
--- /dev/null
@@ -0,0 +1,32 @@
+-- Copyright © 2021 Damyan Ivanov.
+-- This file is part of MoLe.
+-- MoLe is free software: you can distribute it and/or modify it
+-- under the term of the GNU General Public License as published by
+-- the Free Software Foundation, either version 3 of the License, or
+-- (at your opinion), any later version.
+--
+-- MoLe is distributed in the hope that it will be useful,
+-- but WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+-- GNU General Public License terms for details.
+--
+-- You should have received a copy of the GNU General Public License
+-- along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+
+-- migrate from revision 60 to revision 61
+
+-- pragmas need to be outside of transaction control
+-- foreign_keys is needed so that foreign key constraints are redirected
+
+commit transaction;
+pragma foreign_keys = on;
+
+begin transaction;
+
+alter table transactions
+add description_uc text not null default '';
+
+update transactions
+set description_uc=upper(description);
+
+delete from options where name='last_scrape';
\ No newline at end of file
diff --git a/app/src/main/res/raw/db_62.sql b/app/src/main/res/raw/db_62.sql
new file mode 100644 (file)
index 0000000..279400d
--- /dev/null
@@ -0,0 +1,29 @@
+-- Copyright © 2021 Damyan Ivanov.
+-- This file is part of MoLe.
+-- MoLe is free software: you can distribute it and/or modify it
+-- under the term of the GNU General Public License as published by
+-- the Free Software Foundation, either version 3 of the License, or
+-- (at your opinion), any later version.
+--
+-- MoLe is distributed in the hope that it will be useful,
+-- but WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+-- GNU General Public License terms for details.
+--
+-- You should have received a copy of the GNU General Public License
+-- along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+
+-- migrate from revision 61 to revision 62
+
+-- pragmas need to be outside of transaction control
+-- foreign_keys is needed so that foreign key constraints are redirected
+
+commit transaction;
+pragma foreign_keys = on;
+
+begin transaction;
+
+delete from currencies
+where id not in (select min(id) from currencies group by name);
+
+create unique index currency_name_idx on currencies(name);
\ No newline at end of file
diff --git a/app/src/main/res/raw/db_63.sql b/app/src/main/res/raw/db_63.sql
new file mode 100644 (file)
index 0000000..4b11f7f
--- /dev/null
@@ -0,0 +1,63 @@
+-- Copyright © 2021 Damyan Ivanov.
+-- This file is part of MoLe.
+-- MoLe is free software: you can distribute it and/or modify it
+-- under the term of the GNU General Public License as published by
+-- the Free Software Foundation, either version 3 of the License, or
+-- (at your opinion), any later version.
+--
+-- MoLe is distributed in the hope that it will be useful,
+-- but WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+-- GNU General Public License terms for details.
+--
+-- You should have received a copy of the GNU General Public License
+-- along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+
+-- migrate from revision 62 to revision 63
+
+-- pragmas need to be outside of transaction control
+-- foreign_keys is needed so that foreign key constraints are redirected
+
+commit transaction;
+pragma foreign_keys = off;
+
+begin transaction;
+
+CREATE TABLE new_templates (
+    id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
+    name TEXT NOT NULL,
+    uuid TEXT NOT NULL,
+    regular_expression TEXT NOT NULL,
+    test_text TEXT,
+    transaction_description TEXT,
+    transaction_description_match_group INTEGER,
+    transaction_comment TEXT,
+    transaction_comment_match_group INTEGER,
+    date_year INTEGER,
+    date_year_match_group INTEGER,
+    date_month INTEGER,
+    date_month_match_group INTEGER,
+    date_day INTEGER,
+    date_day_match_group INTEGER,
+    is_fallback INTEGER NOT NULL DEFAULT 0);
+
+insert into new_templates(id, name, uuid, regular_expression, test_text,
+    transaction_description, transaction_description_match_group,
+    transaction_comment, transaction_comment_match_group,
+    date_year, date_year_match_group,
+    date_month, date_month_match_group,
+    date_day, date_day_match_group,
+    is_fallback)
+select id, name, random(), regular_expression, test_text,
+       transaction_description, transaction_description_match_group,
+       transaction_comment, transaction_comment_match_group,
+       date_year, date_year_match_group,
+       date_month, date_month_match_group,
+       date_day, date_day_match_group,
+       is_fallback
+from templates;
+
+drop table templates;
+alter table new_templates rename to templates;
+
+create unique index templates_uuid_idx on templates(uuid);
\ No newline at end of file
diff --git a/app/src/main/res/raw/db_64.sql b/app/src/main/res/raw/db_64.sql
new file mode 100644 (file)
index 0000000..e463e94
--- /dev/null
@@ -0,0 +1,62 @@
+-- Copyright © 2021 Damyan Ivanov.
+-- This file is part of MoLe.
+-- MoLe is free software: you can distribute it and/or modify it
+-- under the term of the GNU General Public License as published by
+-- the Free Software Foundation, either version 3 of the License, or
+-- (at your opinion), any later version.
+--
+-- MoLe is distributed in the hope that it will be useful,
+-- but WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+-- GNU General Public License terms for details.
+--
+-- You should have received a copy of the GNU General Public License
+-- along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+
+-- migrate from revision 63 to revision 64
+
+-- pragmas need to be outside of transaction control
+-- foreign_keys is needed so that foreign key constraints are redirected
+
+commit transaction;
+pragma foreign_keys = off;
+
+begin transaction;
+
+-- profiles
+CREATE TABLE profiles_new (
+id INTEGER NOT NULL PRIMARY KEY,
+uuid TEXT NOT NULL,
+name TEXT NOT NULL,
+url TEXT NOT NULL,
+use_authentication INTEGER NOT NULL,
+auth_user TEXT,
+auth_password TEXT,
+order_no INTEGER NOT NULL,
+permit_posting INTEGER NOT NULL,
+theme INTEGER NOT NULL DEFAULT -1,
+preferred_accounts_filter TEXT,
+future_dates INTEGER NOT NULL,
+api_version INTEGER NOT NULL,
+show_commodity_by_default INTEGER NOT NULL,
+default_commodity TEXT,
+show_comments_by_default INTEGER NOT NULL DEFAULT 1,
+detected_version_pre_1_19 INTEGER NOT NULL,
+detected_version_major INTEGER NOT NULL,
+detected_version_minor INTEGER NOT NULL);
+
+insert into profiles_new(
+       uuid, name, url, use_authentication, auth_user, auth_password,
+       order_no, permit_posting, theme, preferred_accounts_filter, future_dates, api_version,
+       show_commodity_by_default, default_commodity, show_comments_by_default, detected_version_pre_1_19,
+       detected_version_major, detected_version_minor)
+select coalesce(deprecated_uuid, random()), name, url, use_authentication, auth_user, auth_password,
+       order_no, permit_posting, theme, preferred_accounts_filter, future_dates, api_version,
+       show_commodity_by_default, default_commodity, show_comments_by_default, detected_version_pre_1_19,
+       detected_version_major, detected_version_minor
+from profiles;
+
+drop table profiles;
+alter table profiles_new rename to profiles;
+
+create unique index profiles_uuid_idx on profiles(uuid);
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_0.sql b/app/src/main/res/raw/sql_0.sql
deleted file mode 100644 (file)
index 3d05c51..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-create table if not exists accounts(name varchar);
-create index if not exists idx_accounts_name on accounts(name);
-create table if not exists options(name varchar, value varchar);
-create unique index if not exists idx_options_name on options(name);
-create table if not exists account_values(account varchar not null, currency varchar not null, value decimal(18,2) not null);
-create index if not exists idx_account_values_account on account_values(account);
-create unique index if not exists un_account_values on account_values(account,currency);
diff --git a/app/src/main/res/raw/sql_1.sql b/app/src/main/res/raw/sql_1.sql
deleted file mode 100644 (file)
index 2dfa141..0000000
+++ /dev/null
@@ -1,2 +0,0 @@
-alter table accounts add keep boolean;
-alter table account_values add keep boolean;
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_10.sql b/app/src/main/res/raw/sql_10.sql
deleted file mode 100644 (file)
index 48e7b79..0000000
+++ /dev/null
@@ -1,2 +0,0 @@
-delete from transaction_accounts;
-delete from transactions;
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_11.sql b/app/src/main/res/raw/sql_11.sql
deleted file mode 100644 (file)
index 356aa16..0000000
+++ /dev/null
@@ -1,2 +0,0 @@
-create table profiles(uuid varchar not null primary key, name not null, url not null, use_authentication boolean not null, auth_user varchar, auth_password varchar);
-create unique index un_profile_name on profiles(name);
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_12.sql b/app/src/main/res/raw/sql_12.sql
deleted file mode 100644 (file)
index f64bc5a..0000000
+++ /dev/null
@@ -1 +0,0 @@
-drop index un_profile_name;
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_13.sql b/app/src/main/res/raw/sql_13.sql
deleted file mode 100644 (file)
index e8038e9..0000000
+++ /dev/null
@@ -1,22 +0,0 @@
-delete from options where name='transaction_list_last_update';
-delete from options where name='last_refresh';
-alter table options add profile varchar;
-drop index idx_options_name;
-create unique index un_options on options(profile,name);
---
-drop table account_values;
-create table account_values(profile varchar not null, account varchar not null, currency varchar not null default '', keep boolean, value decimal not null );
-create unique index un_account_values on account_values(profile,account,currency);
---
-drop table accounts;
-create table accounts(profile varchar not null, name varchar not null, name_upper varchar not null, hidden boolean not null default 0, keep boolean not null default 0, level integer not null, parent_name varchar);
-create unique index un_accounts on accounts(profile, name);
---
-drop table transaction_accounts;
-drop table transactions;
---
-create table transactions(id integer not null, data_hash varchar not null, date varchar not null, description varchar not null, keep boolean not null default 0);
-create unique index un_transactions_id on transactions(id);
-create unique index un_transactions_data_hash on transactions(data_hash);
---
-create table transaction_accounts(profile varchar not null, transaction_id integer not null, account_name varchar not null, currency varchar not null default '', amount decimal not null, constraint fk_transaction_accounts_acc foreign key(profile,account_name) references accounts(profile,account_name), constraint fk_transaction_accounts_trn foreign key(transaction_id) references transactions(id));
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_14.sql b/app/src/main/res/raw/sql_14.sql
deleted file mode 100644 (file)
index ddc634e..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-drop table transaction_accounts;
-drop table transactions;
---
-create table transactions(profile varchar not null, id integer not null, data_hash varchar not null, date varchar not null, description varchar not null, keep boolean not null default 0);
-create unique index un_transactions_id on transactions(profile,id);
-create unique index un_transactions_data_hash on transactions(profile,data_hash);
---
-create table transaction_accounts(profile varchar not null, transaction_id integer not null, account_name varchar not null, currency varchar not null default '', amount decimal not null, constraint fk_transaction_accounts_acc foreign key(profile,account_name) references accounts(profile,account_name), constraint fk_transaction_accounts_trn foreign key(profile, transaction_id) references transactions(profile,id));
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_15.sql b/app/src/main/res/raw/sql_15.sql
deleted file mode 100644 (file)
index 56eb75e..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
-delete from options where profile is null and name='last_scrape';
-create table new_options(profile varchar not null, name varchar not null, value varchar);
-
-insert into new_options(profile, name, value) select distinct '-', o.name, (select o2.value from options o2 where o2.name=o.name and o2.profile is null) from options o where o.profile is null;
-insert into new_options(profile, name, value) select distinct o.profile, o.name, (select o2.value from options o2 where o2.name=o.name and o2.profile=o.profile) from options o where o.profile is not null;
-drop table options;
-create table options(profile varchar not null, name varchar not null, value varchar);
-create unique index un_options on options(profile,name);
-insert into options(profile,name,value) select profile,name,value from new_options;
-drop table new_options;
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_16.sql b/app/src/main/res/raw/sql_16.sql
deleted file mode 100644 (file)
index 235f655..0000000
+++ /dev/null
@@ -1 +0,0 @@
-alter table profiles add order_no integer;
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_17.sql b/app/src/main/res/raw/sql_17.sql
deleted file mode 100644 (file)
index f6f7eeb..0000000
+++ /dev/null
@@ -1,2 +0,0 @@
-alter table profiles add permit_posting boolean default 0;
-update profiles set permit_posting = 1;
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_18.sql b/app/src/main/res/raw/sql_18.sql
deleted file mode 100644 (file)
index bea40aa..0000000
+++ /dev/null
@@ -1,2 +0,0 @@
-alter table profiles add theme integer default -1;
-update profiles set theme = -1;
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_19.sql b/app/src/main/res/raw/sql_19.sql
deleted file mode 100644 (file)
index c2bdbaf..0000000
+++ /dev/null
@@ -1,2 +0,0 @@
-alter table accounts add expanded default 1;
-update accounts set expanded = 1;
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_2.sql b/app/src/main/res/raw/sql_2.sql
deleted file mode 100644 (file)
index bd27382..0000000
+++ /dev/null
@@ -1 +0,0 @@
-create table description_history(description varchar not null primary key, keep boolean);
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_20.sql b/app/src/main/res/raw/sql_20.sql
deleted file mode 100644 (file)
index 0485520..0000000
+++ /dev/null
@@ -1,4 +0,0 @@
-delete from accounts where not exists (select 1 from profiles where uuid = profile);
-delete from account_values where not exists (select 1 from profiles where uuid = profile);
-delete from transactions where not exists (select 1 from profiles where uuid = profile);
-delete from transaction_accounts where not exists (select 1 from profiles where uuid = profile);
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_21.sql b/app/src/main/res/raw/sql_21.sql
deleted file mode 100644 (file)
index 19d37d8..0000000
+++ /dev/null
@@ -1 +0,0 @@
-alter table accounts add amounts_expanded boolean default 0;
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_22.sql b/app/src/main/res/raw/sql_22.sql
deleted file mode 100644 (file)
index 552397c..0000000
+++ /dev/null
@@ -1 +0,0 @@
-alter table profiles add preferred_accounts_filter varchar;
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_23.sql b/app/src/main/res/raw/sql_23.sql
deleted file mode 100644 (file)
index ff3b8f7..0000000
+++ /dev/null
@@ -1 +0,0 @@
-alter table profiles add future_dates integer;
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_24.sql b/app/src/main/res/raw/sql_24.sql
deleted file mode 100644 (file)
index 84ed1ae..0000000
+++ /dev/null
@@ -1 +0,0 @@
-alter table profiles add api_version integer;
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_25.sql b/app/src/main/res/raw/sql_25.sql
deleted file mode 100644 (file)
index d797a36..0000000
+++ /dev/null
@@ -1 +0,0 @@
-create table currencies(id integer not null primary key, name varchar not null, position varchar not null, has_gap boolean not null);
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_26.sql b/app/src/main/res/raw/sql_26.sql
deleted file mode 100644 (file)
index 5ef1102..0000000
+++ /dev/null
@@ -1 +0,0 @@
-alter table transaction_accounts add comment varchar;
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_27.sql b/app/src/main/res/raw/sql_27.sql
deleted file mode 100644 (file)
index 0d1e41c..0000000
+++ /dev/null
@@ -1 +0,0 @@
-alter table profiles add show_commodity_by_default boolean default 0;
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_28.sql b/app/src/main/res/raw/sql_28.sql
deleted file mode 100644 (file)
index fa509c1..0000000
+++ /dev/null
@@ -1 +0,0 @@
-alter table profiles add default_commodity varchar;
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_29.sql b/app/src/main/res/raw/sql_29.sql
deleted file mode 100644 (file)
index 0301ba3..0000000
+++ /dev/null
@@ -1 +0,0 @@
-create index idx_transaction_description on transactions(description);
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_3.sql b/app/src/main/res/raw/sql_3.sql
deleted file mode 100644 (file)
index ceb373e..0000000
+++ /dev/null
@@ -1,4 +0,0 @@
-alter table description_history add description_upper varchar;
-update description_history set description_upper = upper(description);
-alter table accounts add name_upper varchar;
-update accounts set name_upper = upper(name);
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_30.sql b/app/src/main/res/raw/sql_30.sql
deleted file mode 100644 (file)
index c82adf8..0000000
+++ /dev/null
@@ -1 +0,0 @@
-delete from options where profile <> '-' and not exists (select 1 from profiles p where p.uuid=options.profile);
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_31.sql b/app/src/main/res/raw/sql_31.sql
deleted file mode 100644 (file)
index 0f907e7..0000000
+++ /dev/null
@@ -1 +0,0 @@
-alter table profiles add show_comments_by_default boolean default 0;
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_32.sql b/app/src/main/res/raw/sql_32.sql
deleted file mode 100644 (file)
index b858644..0000000
+++ /dev/null
@@ -1 +0,0 @@
-update profiles set show_comments_by_default = 1;
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_33.sql b/app/src/main/res/raw/sql_33.sql
deleted file mode 100644 (file)
index e6f5249..0000000
+++ /dev/null
@@ -1 +0,0 @@
-alter table transactions add comment varchar;
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_34.sql b/app/src/main/res/raw/sql_34.sql
deleted file mode 100644 (file)
index e03541d..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
--- Copyright © 2020 Damyan Ivanov.
--- This file is part of MoLe.
--- MoLe is free software: you can distribute it and/or modify it
--- under the term of the GNU General Public License as published by
--- the Free Software Foundation, either version 3 of the License, or
--- (at your opinion), any later version.
---
--- MoLe is distributed in the hope that it will be useful,
--- but WITHOUT ANY WARRANTY; without even the implied warranty of
--- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
--- GNU General Public License terms for details.
---
--- You should have received a copy of the GNU General Public License
--- along with MoLe. If not, see <https://www.gnu.org/licenses/>.
-alter table transactions add year integer not null default 0;
-alter table transactions add month integer not null default 0;
-alter table transactions add day integer not null default 0;
-alter table transactions add tmp_md varchar;
-update transactions set year= cast(substr(date,  1,instr(date,  '/')-1) as integer);
-update transactions set tmp_md=    substr(date,    instr(date,  '/')+1);
-update transactions set month=cast(substr(tmp_md,1,instr(tmp_md,'/')-1) as integer);
-update transactions set day=  cast(substr(tmp_md,  instr(tmp_md,'/')+1) as integer);
--- alter table transactions drop date
-create table transactions_2(profile varchar not null, id integer not null, data_hash varchar not null, year integer not null, month integer not null, day integer not null, description varchar not null, comment varchar, keep boolean not null default 0);
-insert into transactions_2(profile, id, data_hash, year, month, day, description, comment, keep) select profile, id, data_hash, year, month, day, description, comment, keep from transactions;
-drop table transactions;
-alter table transactions_2 rename to transactions;
diff --git a/app/src/main/res/raw/sql_35.sql b/app/src/main/res/raw/sql_35.sql
deleted file mode 100644 (file)
index 0a77c3e..0000000
+++ /dev/null
@@ -1,18 +0,0 @@
--- Copyright © 2020 Damyan Ivanov.
--- This file is part of MoLe.
--- MoLe is free software: you can distribute it and/or modify it
--- under the term of the GNU General Public License as published by
--- the Free Software Foundation, either version 3 of the License, or
--- (at your opinion), any later version.
---
--- MoLe is distributed in the hope that it will be useful,
--- but WITHOUT ANY WARRANTY; without even the implied warranty of
--- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
--- GNU General Public License terms for details.
---
--- You should have received a copy of the GNU General Public License
--- along with MoLe. If not, see <https://www.gnu.org/licenses/>.
-create table accounts_new(profile varchar not null, name varchar not null, name_upper varchar not null, keep boolean not null default 0, level integer not null, parent_name varchar, expanded default 1, amounts_expanded boolean default 0);
-insert into accounts_new(profile, name, name_upper, keep, level, parent_name, expanded, amounts_expanded) select profile, name, name_upper, keep, level, parent_name, expanded, amounts_expanded from accounts;
-drop table accounts;
-alter table accounts_new rename to accounts;
diff --git a/app/src/main/res/raw/sql_36.sql b/app/src/main/res/raw/sql_36.sql
deleted file mode 100644 (file)
index 378eddc..0000000
+++ /dev/null
@@ -1,18 +0,0 @@
--- Copyright © 2020 Damyan Ivanov.
--- This file is part of MoLe.
--- MoLe is free software: you can distribute it and/or modify it
--- under the term of the GNU General Public License as published by
--- the Free Software Foundation, either version 3 of the License, or
--- (at your opinion), any later version.
---
--- MoLe is distributed in the hope that it will be useful,
--- but WITHOUT ANY WARRANTY; without even the implied warranty of
--- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
--- GNU General Public License terms for details.
---
--- You should have received a copy of the GNU General Public License
--- along with MoLe. If not, see <https://www.gnu.org/licenses/>.
-alter table accounts add generation integer default 0;
-alter table account_values add generation integer default 0;
-alter table transactions add generation integer default 0;
-alter table transaction_accounts add generation integer default 0;
diff --git a/app/src/main/res/raw/sql_37.sql b/app/src/main/res/raw/sql_37.sql
deleted file mode 100644 (file)
index 60b6e93..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
--- Copyright © 2020 Damyan Ivanov.
--- This file is part of MoLe.
--- MoLe is free software: you can distribute it and/or modify it
--- under the term of the GNU General Public License as published by
--- the Free Software Foundation, either version 3 of the License, or
--- (at your opinion), any later version.
---
--- MoLe is distributed in the hope that it will be useful,
--- but WITHOUT ANY WARRANTY; without even the implied warranty of
--- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
--- GNU General Public License terms for details.
---
--- You should have received a copy of the GNU General Public License
--- along with MoLe. If not, see <https://www.gnu.org/licenses/>.
-alter table transaction_accounts add order_no integer not null default 0;
-update transaction_accounts set order_no = rowid;
-create unique index un_transaction_accounts_order on transaction_accounts(profile, transaction_id, order_no);
diff --git a/app/src/main/res/raw/sql_38.sql b/app/src/main/res/raw/sql_38.sql
deleted file mode 100644 (file)
index 60c8611..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
--- Copyright © 2020 Damyan Ivanov.
--- This file is part of MoLe.
--- MoLe is free software: you can distribute it and/or modify it
--- under the term of the GNU General Public License as published by
--- the Free Software Foundation, either version 3 of the License, or
--- (at your opinion), any later version.
---
--- MoLe is distributed in the hope that it will be useful,
--- but WITHOUT ANY WARRANTY; without even the implied warranty of
--- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
--- GNU General Public License terms for details.
---
--- You should have received a copy of the GNU General Public License
--- along with MoLe. If not, see <https://www.gnu.org/licenses/>.
-CREATE TABLE transaction_accounts_new(profile varchar not null, transaction_id integer not null, account_name varchar not null, currency varchar not null default '', amount decimal not null, comment varchar, generation integer default 0, order_no integer not null default 0, constraint fk_transaction_accounts_acc foreign key(profile,account_name) references accounts(profile,name), constraint fk_transaction_accounts_trn foreign key(profile, transaction_id) references transactions(profile,id));
-insert into transaction_accounts_new(profile, transaction_id, account_name, currency, amount, comment, generation, order_no) select profile, transaction_id, account_name, currency, amount, comment, generation, order_no from transaction_accounts;
-drop table transaction_accounts;
-alter table transaction_accounts_new rename to transaction_accounts;
-create unique index un_transaction_accounts_order on transaction_accounts(profile, transaction_id, order_no);
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_39.sql b/app/src/main/res/raw/sql_39.sql
deleted file mode 100644 (file)
index 0312574..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
--- Copyright © 2020 Damyan Ivanov.
--- This file is part of MoLe.
--- MoLe is free software: you can distribute it and/or modify it
--- under the term of the GNU General Public License as published by
--- the Free Software Foundation, either version 3 of the License, or
--- (at your opinion), any later version.
---
--- MoLe is distributed in the hope that it will be useful,
--- but WITHOUT ANY WARRANTY; without even the implied warranty of
--- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
--- GNU General Public License terms for details.
---
--- You should have received a copy of the GNU General Public License
--- along with MoLe. If not, see <https://www.gnu.org/licenses/>.
-create table description_history_new(description varchar not null primary key, description_upper varchar, generation integer default 0);
-insert into description_history_new(description, description_upper) select description, description_upper from description_history;
-drop table description_history;
-alter table description_history_new rename to description_history;
-create unique index un_description_history on description_history(description_upper);
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_4.sql b/app/src/main/res/raw/sql_4.sql
deleted file mode 100644 (file)
index d1a14d7..0000000
+++ /dev/null
@@ -1,2 +0,0 @@
-alter table accounts add hidden boolean default 0;
-update accounts set hidden = 0;
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_40.sql b/app/src/main/res/raw/sql_40.sql
deleted file mode 100644 (file)
index 18a350a..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
--- Copyright © 2020 Damyan Ivanov.
--- This file is part of MoLe.
--- MoLe is free software: you can distribute it and/or modify it
--- under the term of the GNU General Public License as published by
--- the Free Software Foundation, either version 3 of the License, or
--- (at your opinion), any later version.
---
--- MoLe is distributed in the hope that it will be useful,
--- but WITHOUT ANY WARRANTY; without even the implied warranty of
--- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
--- GNU General Public License terms for details.
---
--- You should have received a copy of the GNU General Public License
--- along with MoLe. If not, see <https://www.gnu.org/licenses/>.
-pragma foreign_keys=off;
-delete from transaction_accounts where not exists (select 1 from accounts a where a.profile=transaction_accounts.profile and a.name=transaction_accounts.account_name);
-delete from transaction_accounts where not exists (select 1 from transactions t where t.profile=transaction_accounts.profile and t.id=transaction_accounts.transaction_id);
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_41.sql b/app/src/main/res/raw/sql_41.sql
deleted file mode 100644 (file)
index 5026276..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
--- Copyright © 2020 Damyan Ivanov.
--- This file is part of MoLe.
--- MoLe is free software: you can distribute it and/or modify it
--- under the term of the GNU General Public License as published by
--- the Free Software Foundation, either version 3 of the License, or
--- (at your opinion), any later version.
---
--- MoLe is distributed in the hope that it will be useful,
--- but WITHOUT ANY WARRANTY; without even the implied warranty of
--- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
--- GNU General Public License terms for details.
---
--- You should have received a copy of the GNU General Public License
--- along with MoLe. If not, see <https://www.gnu.org/licenses/>.
-alter table profiles add detected_version_pre_1_19 boolean;
-alter table profiles add detected_version_major integer;
-alter table profiles add detected_version_minor integer;
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_5.sql b/app/src/main/res/raw/sql_5.sql
deleted file mode 100644 (file)
index 309f82e..0000000
+++ /dev/null
@@ -1,2 +0,0 @@
-alter table accounts add level integer;
-alter table accounts add parent varchar;
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_6.sql b/app/src/main/res/raw/sql_6.sql
deleted file mode 100644 (file)
index c8e8634..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-drop index idx_accounts_name;
-create table accounts_tmp(name varchar not null, name_upper varchar not null primary key, hidden boolean not null default 0, level integer not null default 0, parent_name varchar);
-insert or replace into accounts_tmp(name, name_upper, hidden, level, parent_name) select name, name_upper, hidden, level, parent from accounts;
-drop table accounts;
-create table accounts(name varchar not null, name_upper varchar not null primary key, hidden boolean not null default 0, level integer not null default 0, parent_name varchar, keep boolean default 1);
-insert into accounts(name, name_upper, hidden, level, parent_name) select name, name_upper, hidden, level, parent_name from accounts_tmp;
-drop table accounts_tmp;
diff --git a/app/src/main/res/raw/sql_7.sql b/app/src/main/res/raw/sql_7.sql
deleted file mode 100644 (file)
index 5217e5d..0000000
+++ /dev/null
@@ -1,2 +0,0 @@
-create table transactions(id varchar primary key, date varchar, description varchar);
-create table transaction_accounts(transaction_id integer not null, account_name varchar not null, amount float, currency varchar, foreign key (transaction_id) references transactions(id), foreign key(account_name) references accounts(name));
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_8.sql b/app/src/main/res/raw/sql_8.sql
deleted file mode 100644 (file)
index 0f06539..0000000
+++ /dev/null
@@ -1,2 +0,0 @@
-alter table transactions add data_hash varchar;
-delete from transactions;
\ No newline at end of file
diff --git a/app/src/main/res/raw/sql_9.sql b/app/src/main/res/raw/sql_9.sql
deleted file mode 100644 (file)
index 936cec6..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-alter table transactions add keep boolean default 1 not null;
-update transactions set keep = 1;
-create table transactions_new(id integer, date varchar, description varchar, data_hash varchar, keep boolean);
-insert into transactions_new(id, date, description, data_hash, keep) select cast(id as integer), date, description, data_hash, keep from transactions;
-drop table transactions;
-create table transactions(id integer primary key, date varchar, description varchar, data_hash varchar, keep boolean);
-create unique index un_transactions_data_hash on transactions(data_hash);
-insert into transactions(id, date, description, data_hash, keep) select id, date, description, data_hash, keep from transactions_new;
-drop table transactions_new;
\ No newline at end of file
index b475943c5fdd85b414c9b40946d1154a66c95af3..88598bf65a4e2e107a3fae340c007338443fec47 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?><!--
-  ~ Copyright © 2019 Damyan Ivanov.
+  ~ Copyright © 2021 Damyan Ivanov.
   ~ This file is part of MoLe.
   ~ MoLe is free software: you can distribute it and/or modify it
   ~ under the term of the GNU General Public License as published by
         <!--<item>Expand all</item>-->
         <!--<item>Collapse all</item>-->
     </string-array>
+    <string-array name="templates_ctx_menu">
+        <item>Промяна</item>
+        <item>Дублиране</item>
+        <item>Изтриване</item>
+    </string-array>
+    <string-array name="template_list_help_text">
+        <item>Макетите са като предварително попълнени движения. Някои от параметрите на движението са указани в макета, а други се извличат от външен източник.</item>
+        <item>Например, при въвеждане на ново движение може да се сканира QR код от касова бележка, което да доведе до автоматично попълване на описанието на движението и имената на сметките от макета и попълване на датата и сумата от данните в QR кода.</item>
+        <item>Макетите описват кои параметри на движението са фиксирани и кои идват от външния източник.</item>
+        <item>Сканирането на QR код е единственият външен източник, който се поддържа в момента. В бъдеще е планирана работа с поставяне на текст от работния буфер и четене/прихващане на текстови съобщения (SMS).</item>
+    </string-array>
+    <string-array name="template_params_help">
+        <item>Шаблонът е регулярен израз ([Уикипедия↗](https://bg.wikipedia.org/wiki/Регулярен_израз#Синтаксис)). При получаване на данни от външния източник, шаблонът се напасва към тях за да се определи кои макети отговарят на входните данни.</item>
+        <item>Поддържа се използването на разпознати групи за попълване на параметри на движението от входните данни.</item>
+    </string-array>
 </resources>
index 71308242f75237f3dd557310e0f7c766ee4de5b0..ccb47516c7ce94db047f23881c99479810dc84c8 100644 (file)
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
 <!--
-  ~ Copyright © 2020 Damyan Ivanov.
+  ~ Copyright © 2024 Damyan Ivanov.
   ~ This file is part of MoLe.
   ~ MoLe is free software: you can distribute it and/or modify it
   ~ under the term of the GNU General Public License as published by
   -->
 
 <resources>
-    <string name="title_activity_settings">Настройки</string>
-    <string name="pref_header_backend">Сървър</string>
     <string name="pref_title_use_http_auth">Удостоверяване</string>
-    <string name="pref_description_use_http_auth_on">Използване на удостоверяване при свързване със сървъра</string>
-    <string name="pref_description_use_http_auth_off">Без използване на удостоверяване при свързване със сървъра</string>
-    <string name="pref_title_backend_url">Адрес на сървъра</string>
     <string name="nav_reports_title">Справки</string>
     <string name="nav_transactions_title">Движения</string>
     <string name="action_settings">Настройки</string>
     <string name="pref_title_backend_auth_user">Потребител</string>
     <string name="pref_title_backend_auth_password">Парола</string>
-    <string name="title_activity_new_transaction">Ð\9dова Ñ\82Ñ\80анзакÑ\86иÑ\8f</string>
+    <string name="title_activity_new_transaction">Ð\9dово Ð´Ð²Ð¸Ð¶ÐµÐ½Ð¸Ðµ</string>
     <string name="new_transaction_account_hint">Сметка</string>
     <string name="new_transaction_date_hint">днес</string>
-    <string name="msg_at_least_two_accounts_are_required">Задължително е използването на поне две сметки</string>
-    <string name="err_net_io_error">Мрежова грешка</string>
-    <string name="err_bad_backend_url">Неправилен адрес на сървър</string>
-    <string name="progress_connecting">Свързване…</string>
     <string name="new_transaction_description_hint">Описание</string>
     <string name="account_summary_title">Сметки</string>
-    <string name="menu_hide_acc_condensed_title">Скриване</string>
-    <string name="menu_acc_view_transactions">Преглед на транзакциите</string>
-    <string name="menu_acc_summary_refresh_title">Обновяване</string>
-    <string name="err_net_error">Мрежова грешка</string>
-    <string name="menu_acc_summary_show_only_starred_title">Показване само на любимите</string>
     <string name="action_reset_new_transaction_activity_title">Отначало</string>
-    <string name="interface_pref_header_title">Интерфейс</string>
-    <string name="pref_show_only_starred_off_summary">Списъкът на сметките съдържа всички сметки</string>
-    <string name="pref_show_only_starred_on_summary">Показват се само отбелязаните със звезда</string>
-    <string name="menu_acc_summary_cancel_selection_title">Отказ</string>
-    <string name="err_bad_auth">Грешно потребителско име или парола</string>
-    <string name="new_transaction_amount_hint">0,00</string>
-    <string name="transactions_last_update_label">Данни към:</string>
-    <string name="title_profile_list">Профили</string>
     <string name="profiles">Профили</string>
     <string name="title_profile_details">Данни за профила</string>
-    <string name="transaction_last_update_never">никога</string>
-    <string name="err_cancelled">Операцията е прекъсната</string>
-    <string name="title_activity_transaction_list">Транзакции</string>
-    <string name="err_http_error">Грешка в HTTP</string>
     <string name="new_profile_title">Нов профил</string>
     <string name="delete_profile">Изтриване на профила</string>
     <string name="delete">Изтриване</string>
@@ -74,7 +48,6 @@
         <item>Ноември</item>
         <item>Декември</item>
     </string-array>
-    <string name="error_invalid_date">Грешна дата</string>
     <string name="url_label">Адрес</string>
     <string name="profile_name_label">Име на профила</string>
     <string name="create_profile_label">Създаване на профил</string>
@@ -84,7 +57,7 @@
     <string name="err_profile_url_empty">Моля, въведете адрес, например https://server/location</string>
     <string name="err_profile_user_name_empty">Въвеждането на потребителско име е задължително когато се използва удостоверяване</string>
     <string name="err_profile_password_empty">Паролата е задължителна</string>
-    <string name="posting_permitted">Позволяване на добавянето на нови транзакции</string>
+    <string name="posting_permitted">Позволяване на добавянето на нови движения</string>
     <string name="send_crash_via">Изпращане на доклада чрез:</string>
     <string name="crash_send_question">Желаете ли да изпратите доклад за грешката на автора? Това ще помогне за диагностициране и отстраняване на проблема. Докладът се изпраща по email, като имате възможност за преглед преди изпращане.</string>
     <string name="crash_app_condensed_label">Срив</string>
     <string name="btn_send_crash_report">Изпращане…</string>
     <string name="crash_app_label">Тестов срив</string>
     <string name="crash_dialog_title">MoLe се срина</string>
-    <string name="crash_report_contents_label">Съдържание на доклада:</string>
     <string name="profile_subtitle_read_only">(Само за преглед)</string>
-    <string name="menu_acc_summary_confirm_selection_title">Потвърждаване на избора</string>
-    <string name="menu_acc_summary_hide_selected_title">Скриване на маркираните сметки</string>
     <string name="btn_show_report">Показване на доклада</string>
     <string name="btn_select_label">Избор</string>
     <string name="profile_color_label">Цвят на профила</string>
     <string name="default_color_btn">По подразбиране</string>
-    <string name="btn_cancel">Отказ</string>
-    <string name="btn_ok">Добре</string>
     <string name="profile_list_rearrange_handle_label">Манипулатор за промяна на подредбата</string>
-    <string name="color_label">Цвят</string>
-    <string name="pref_preferred_autocompletion_account_filter_hint" >Филтър при избор на предишна транзакция</string>
+    <string name="pref_preferred_autocompletion_account_filter_hint">Филтър при избор на предишно движение</string>
     <string name="remove_profile_dialog_message">Потвърдете окончателното премахване на профила</string>
     <string name="Remove">Премахване</string>
-    <string name="text_loading">Зареждане…</string>
     <string name="err_invalid_url">Грешен или непълен адрес</string>
     <string name="btn_color_picker_button">Бутон за избор на цвят</string>
     <string name="insecure_scheme_with_auth">ВНИМАНИЕ: Използване на удостоверяване с несигурна схема на достъп</string>
     <string name="future_dates_all">Без ограничения</string>
     <string name="future_dates_none">Без въвеждане на бъдещи дати</string>
     <string name="profile_future_dates_label">Въвеждане на дати в бъдещето</string>
-    <string name="api_auto">Ð\90вÑ\82омаÑ\82иÑ\87но Ð¾Ñ\82кÑ\80иване</string>
+    <string name="api_auto">Ð\90вÑ\82омаÑ\82иÑ\87на</string>
     <string name="api_html">Версия преди 1.14</string>
-    <string name="api_post_1_14">Версия 1.15 или по-нова</string>
-    <string name="api_pre_1_15">Версия 1.14.x</string>
-    <string name="profile_api_version_title">Версия на сървъра</string>
+    <string name="api_1_15">Версия 1.15</string>
+    <string name="api_1_14">Версия 1.14</string>
+    <string name="profile_api_version_title">Версия на протокола</string>
     <string name="add_button">Добавяне…</string>
     <string name="close_button">Затваряне</string>
     <string name="transaction_account_comment_hint">бележка</string>
     <string name="show_currency_input">Валута</string>
     <string name="currency_input_by_default">Показване по подразбиране на полето за валута</string>
     <string name="profile_default_commodity">Валута по подразбиране</string>
-    <string name="ignoring_preferred_account">Липсват транзакции с предпочитаната сметка</string>
+    <string name="ignoring_preferred_account">Липсват движения с предпочитаната сметка</string>
     <string name="show_comments_switch">Коментари</string>
     <string name="show_comment_input_by_default">Показване по подразбиране на полетата за бележки</string>
     <string name="icon">икона</string>
     <string name="navigation_drawer_open">Отваряне на страничния панел</string>
     <string name="navigation_drawer_close">Затваряне на страничния панел</string>
     <string name="nav_header_desc">Заглавна част на страничния панел</string>
-    <string name="transaction_count_summary">%,d движения към %s</string>
-    <string name="account_count_summary">%,d сметки към %s</string>
-    <string name="api_version_unknown_label">Неизвестна</string>
-    <string name="api_pre_1_19">Преди 1.20.?</string>
-    <string name="detected_version_label">Открита версия</string>
+    <string name="transaction_count_summary">%1$,d движения към %2$s</string>
+    <string name="account_count_summary">%1$,d сметки към %2$s</string>
+    <string name="server_version_unknown_label">Неизвестна</string>
+    <string name="detected_server_pre_1_20_1">Преди 1.20.1</string>
+    <string name="new_transaction_fab_description">Знак плюс</string>
+    <string name="api_1_19_1">Версия 1.19.1</string>
+    <string name="profile_server_version_title">Версия на сървъра</string>
+    <string name="err_json_parser_error">Грешка при разчитане на отговора от сървъра. Вероятно настроената врсия на протокола не се поддържа.</string>
+    <string name="btn_profile_options">Настройка на профила</string>
+    <string name="err_json_send_error_head">Грешка при изпращане на движението към сървъра</string>
+    <string name="err_json_send_error_tail">Вероятно настроената версия на протокола не се поддържа.</string>
+    <string name="err_json_send_error_unsupported">Възможно е програмния интерфейс на сървъра да не се поддържа от MoLe</string>
+    <string name="scan_qr">Сканиране на QR код</string>
+    <string name="nav_templates">Макети</string>
+    <string name="title_activity_templates">Макети</string>
+    <string name="help_menu_item_title">Помощ</string>
+    <string name="template_details_account_comment_label">Бележка към сметката</string>
+    <string name="template_details_account_amount_label">Сума</string>
+    <string name="choose_template_detail_source_label">Прихващане от шаблона</string>
+    <string name="missing_pattern_error">Липсва шаблон</string>
+    <string name="missing_test_text">Липсва примерен текст</string>
+    <string name="pattern_without_groups">Шаблонът няма прихващания</string>
+    <string name="pattern_does_not_match">Шаблонът не съвпада с примерния текст</string>
+    <string name="template_transaction_parameters_label">Данни за движението</string>
+    <string name="template_transaction_description_hint">Описание на движението</string>
+    <string name="template_transaction_comment_hint">Бележка към движението</string>
+    <string name="transaction_description_source_label">Източник на описанието на движението</string>
+    <string name="transaction_comment_source_label">Източник на бележката към движението</string>
+    <string name="template_details_date_label">Дата на движението</string>
+    <string name="date_year_hint">година</string>
+    <string name="date_month_hint">месец</string>
+    <string name="date_day_hint">ден</string>
+    <string name="template_details_date_year_source_label">година</string>
+    <string name="template_details_date_day_source_label">ден</string>
+    <string name="month_source_label">месец</string>
+    <string name="unnamed_template">Макет без име</string>
+    <string name="add_button_description">Добавяне на макет</string>
+    <string name="no_template_matches">Няма съвпадение с нито един макет</string>
+    <string name="choose_template_to_apply">Избор на макет</string>
+    <string name="title_edit_template">Промяна на макет</string>
+    <string name="title_new_template">Създаване на макет</string>
+    <string name="pattern_has_errors">Шаблонът съдържа грешки</string>
+    <string name="account_name_is_empty">Липсва сметка</string>
+    <string name="pattern_is_empty">Липсва шаблон</string>
+    <string name="invalid_matching_group_number">Невалиден номер на прихващане</string>
+    <string name="template_name_label">Име на макет</string>
+    <string name="template_details_pattern_label">Шаблон</string>
+    <string name="template_details_test_text_label">Примерен текст</string>
+    <string name="template_details_account_name_label">Сметка</string>
+    <string name="template_details_account_row_label">Данни за сметка №%d</string>
+    <string name="account_name_source_label">Източник на името на сметката</string>
+    <string name="template_details_source_literal">ръчно въвеждане</string>
+    <string name="account_comment_source_label">Източник на бележка към сметката</string>
+    <string name="account_amount_source_label">Източник на сумата</string>
+    <string name="template_xxx_deleted">Макетът „%1$s“ е изтрит</string>
+    <string name="action_undo">Връщане</string>
+    <string name="pattern_match_result">Резултат от прилагането на шаблона</string>
+    <string name="template_item_match_group_source">Група %1$d (%2$s)</string>
+    <string name="template_account_keep_amount_sign">Без промяна на знака</string>
+    <string name="template_account_change_amount_sign">Обръщане на знака на сумата (от плюс на минус и от минус на плюс)</string>
+    <string name="template_account_negate_amount_label">Знак на сумата</string>
+    <string name="template_is_fallback_label">Резервен макет</string>
+    <string name="template_is_fallback_yes">Макетът ще се предлага за избор само ако няма друг макет, който да пасва и да не е маркиран като резервен</string>
+    <string name="template_is_fallback_no">Макетът не е резервен</string>
+    <string name="fallback_templates_divider">Резервни макети</string>
+    <string name="template_list_help_title">Макети</string>
+    <string name="template_details_template_params_label">Параметри на макета</string>
+    <string name="template_params_help_description">Помощна информация за пааметрите на макета</string>
+    <string name="account_currency_source_label">Източник на валутата</string>
+    <string name="action_import_export">Резервно копие</string>
+    <string name="backup_header">Резервно копие</string>
+    <string name="backup_button_label">Създаване</string>
+    <string name="restore_header">Възстановяване от резервно копие</string>
+    <string name="restore_button_label">Възстановяване</string>
+    <string name="backups_activity_label">Резервно копие</string>
+    <string name="backup_explanation">Записване на данните за профили и макети във файл във формат JSON. Това включва и паролите в явен вид. Резервното копие може да се използва за възстановяване на настройките на друго устройство или след нулиране. Данните за сметките и движенията по тях не се записват, а ще бъдат изтеглени от сървъра при възстановяване на настройките.</string>
+    <string name="config_saved">Настройките са записани</string>
+    <string name="restore_explanation">Зареждане на настройките на профилите и макетите от резервно копие, създадено по-рано. Съществуващите записи не се променят. Ако искате да върнете някой запис (профил или макет) към състоянитето от резервното копие, първо го изтрийте.</string>
+    <string name="config_restored">Успешно възстановяване на настройките</string>
+    <string name="no_profile_restore_hint">… а може и да възстановите настройките от резервно копие</string>
+    <string name="profile_not_available">Недостъпен профил</string>
+    <string name="api_1_23">Версия 1.23</string>
+    <string name="accounts_menu_show_zero">Сметки с нулев баланс</string>
+    <string name="accounts_menu_show_zero_condensed">Нулеви сметки</string>
 </resources>
index 126fd4fcbf2129132723054750493e5bcbbc2326..586a952975b2dab8f2b1922463ba2547932ff4f2 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?><!--
-  ~ Copyright © 2019 Damyan Ivanov.
+  ~ Copyright © 2021 Damyan Ivanov.
   ~ This file is part of MoLe.
   ~ MoLe is free software: you can distribute it and/or modify it
   ~ under the term of the GNU General Public License as published by
@@ -16,6 +16,5 @@
   -->
 
 <resources>
-    <dimen name="nav_header_vertical_spacing">8dp</dimen>
     <dimen name="activity_vertical_margin">16dp</dimen>
 </resources>
\ No newline at end of file
index 3d6fbf909859f66eaa11d11f8e6eb8cafe9ca3be..1489a08cb2918f3b0f0b080cbbadce6aeaa990de 100644 (file)
@@ -1,5 +1,5 @@
 <!--
-  ~ Copyright © 2020 Damyan Ivanov.
+  ~ Copyright © 2021 Damyan Ivanov.
   ~ This file is part of MoLe.
   ~ MoLe is free software: you can distribute it and/or modify it
   ~ under the term of the GNU General Public License as published by
     <!-- Base application theme. -->
     <!-- base hue: 261.2245° -->
     <!-- target primary color: #935FF2 -->
+    <style name="MoLeAutoCompleteTextViewStyle" parent="Widget.AppCompat.AutoCompleteTextView">
+        <item name="android:popupBackground">#272727</item>
+    </style>
+
+    <style name="MoLeMaterialAutoCompleteTextViewStyle" parent="Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense.ExposedDropdownMenu">
+        <item name="android:popupBackground">#272727</item>
+    </style>
+
     <style name="AppTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar">
         <item name="colorOnPrimary">@android:color/white</item>
-        <item name="drawer_background">?android:attr/popupBackground</item>
         <item name="windowActionBar">false</item>
         <item name="windowNoTitle">true</item>
         <item name="textColor">#ffffff</item>
@@ -33,6 +40,7 @@
         <item name="textInputStyle">
             @style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense
         </item>
+        <item name="autoCompleteTextViewStyle">@style/MoLeAutoCompleteTextViewStyle</item>
         <item name="colorError">#FFE1E2</item>
         <item name="colorOnError">#CD1609</item>
         <item name="main_header_shadow_height">12dp</item>
         <item name="android:textAlignment">viewEnd</item>
     </style>
 
-    <style name="account_summary_account_entry_table">
-        <item name="android:layout_width">match_parent</item>
-        <item name="android:layout_height">match_parent</item>
-    </style>
-
     <style name="transaction_list_comment">
         <item name="android:textAppearance">@android:style/TextAppearance.Material.Small</item>
         <item name="android:textColor">?commentColor</item>
index 25f15fb8129f69ae91e11d4301f1b64721d8fb40..cc4f07b24c5cb7d7ef444f8f8be88566bab32ae1 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?><!--
-  ~ Copyright © 2019 Damyan Ivanov.
+  ~ Copyright © 2021 Damyan Ivanov.
   ~ This file is part of MoLe.
   ~ MoLe is free software: you can distribute it and/or modify it
   ~ under the term of the GNU General Public License as published by
@@ -17,7 +17,4 @@
 
 <resources>
 
-    <style name="StretchedTextView" parent="Widget.AppCompat.TextView">
-        <item name="android:autoSizeTextType">uniform</item>
-    </style>
 </resources>
\ No newline at end of file
index 17b632966ff87c85744dcbed46f00a8f3d6bc949..ded168533667f9b0ce81cdb77b1b99dad0c40d19 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?><!--
-  ~ Copyright © 2019 Damyan Ivanov.
+  ~ Copyright © 2021 Damyan Ivanov.
   ~ This file is part of MoLe.
   ~ MoLe is free software: you can distribute it and/or modify it
   ~ under the term of the GNU General Public License as published by
         <!--<item>Expand all</item>-->
         <!--<item>Collapse all</item>-->
     </string-array>
+    <string-array name="templates_ctx_menu">
+        <item>Edit</item>
+        <item>Duplicate</item>
+        <item>Delete</item>
+    </string-array>
+    <string-array name="template_list_help_text">
+        <item>Templates are like pre-filled transactions. Some of the transaction parameters are defined by the template, and others can be deduced from external source.</item>
+        <item>For example, when adding a new transaction, you could scan the QR code of a receipt and get the transaction description filled from the template, and date and amounts filled from the data in the QR code.</item>
+        <item>Templates describe which transaction parameters are fixed and which come from the external source.</item>
+        <item>Currently, scanning QR codes is the only available source. Support for pasting from the clipboard and reading/intercepting text messages (SMS) is planned for the future.</item>
+    </string-array>
+    <string-array name="template_params_help">
+        <item>The pattern is a Regular expression ([Wikipedia↗](https://en.wikipedia.org/wiki/Regular_expression#Syntax)). It must match the input from the external source, or the template won\'t be considered when looking for templates corresponding to the input from the external source.</item>
+        <item>Capture groups may be used for filling some transaction parameters.</item>
+    </string-array>
 </resources>
\ No newline at end of file
index 9ed82c246f78ceddf78d79b13695e054fd83e247..5e5853575e65199b7ba37bde589b31e2ffc3f8fb 100644 (file)
@@ -1,5 +1,5 @@
 <!--
-  ~ Copyright © 2019 Damyan Ivanov.
+  ~ Copyright © 2021 Damyan Ivanov.
   ~ This file is part of MoLe.
   ~ MoLe is free software: you can distribute it and/or modify it
   ~ under the term of the GNU General Public License as published by
     <!-- Default screen margins, per the Android Design guidelines. -->
     <dimen name="activity_horizontal_margin">16dp</dimen>
     <dimen name="activity_vertical_margin">8dp</dimen>
-    <dimen name="nav_header_vertical_spacing">4dp</dimen>
     <dimen name="fab_margin">16dp</dimen>
     <dimen name="app_bar_height">200dp</dimen>
-    <dimen name="item_width">200dp</dimen>
     <dimen name="text_margin">16dp</dimen>
-    <dimen name="toolbar_height">56sp</dimen>
+    <dimen name="half_text_margin">8dp</dimen>
+    <dimen name="quarter_text_margin">4dp</dimen>
 </resources>
\ No newline at end of file
index bd3cb031d9b2304e49182847711081118f58e18a..52d84690005411a968feef747c3b8d94bf881da9 100644 (file)
@@ -1,5 +1,5 @@
 <!--
-  ~ Copyright © 2020 Damyan Ivanov.
+  ~ Copyright © 2024 Damyan Ivanov.
   ~ This file is part of MoLe.
   ~ MoLe is free software: you can distribute it and/or modify it
   ~ under the term of the GNU General Public License as published by
     <string name="navigation_drawer_close">Close navigation drawer</string>
     <string name="nav_header_desc">Navigation header</string>
     <string name="action_settings">Settings</string>
-    <string name="nav_header_subtitle" translatable="false">dam+google@ktnx.net</string>
     <string name="nav_transactions_title">Transactions</string>
     <string name="nav_reports_title">Reports</string>
-    <string name="title_activity_settings">Settings</string>
 
     <!-- Strings related to Settings -->
 
     <!-- Example General settings -->
-    <string name="pref_header_backend">Backend server</string>
 
     <string name="pref_title_use_http_auth">Enable HTTP authentication</string>
-    <string name="pref_description_use_http_auth_on">Use HTTP authentication (basic) when connecting to the backend</string>
-    <string name="pref_description_use_http_auth_off">Use plain HTTP without authentication when connecting to the backend</string>
 
-    <string name="pref_title_backend_url">Backend URL</string>
     <string name="pref_default_backend_url" translatable="false">https://server/loc</string>
 
     <string name="pref_title_backend_auth_user">Username</string>
     <string name="title_activity_new_transaction">New Transaction</string>
     <string name="new_transaction_account_hint">Account</string>
     <string name="new_transaction_date_hint">today</string>
-    <string name="new_transaction_amount_hint">0.00</string>
-    <string name="msg_at_least_two_accounts_are_required">At least two accounts are required</string>
-    <string name="err_bad_backend_url">Invalid backend URL</string>
-    <string name="err_net_io_error">Network I/O error</string>
-    <string name="err_http_error">HTTP error</string>
-    <string name="err_net_error">Network error</string>
-    <string name="progress_connecting">Connecting…</string>
     <string name="new_transaction_description_hint">Description</string>
     <string name="account_summary_title">Accounts</string>
-    <string name="menu_acc_summary_refresh_title">Refresh</string>
-    <string name="menu_acc_view_transactions">View transactions</string>
-    <string name="menu_hide_acc_condensed_title">Hide</string>
-    <string name="menu_acc_summary_show_only_starred_title">Show only starred</string>
-    <string name="err_bad_auth">Invalid username or password</string>
     <string name="action_reset_new_transaction_activity_title">Reset</string>
-    <string name="interface_pref_header_title">Interface</string>
-    <string name="pref_show_only_starred_off_summary">Account list contains all accounts</string>
-    <string name="pref_show_only_starred_on_summary">Only starred accounts are shown</string>
-    <string name="menu_acc_summary_hide_selected_title">Hide selected accounts</string>
-    <string name="menu_acc_summary_cancel_selection_title">Cancel selection</string>
-    <string name="menu_acc_summary_confirm_selection_title">Confirm selection</string>
-    <string name="title_activity_transaction_list">Transactions</string>
-    <string name="transactions_last_update_label">Last update:</string>
-    <string name="transaction_last_update_never">never</string>
-    <string name="err_cancelled">Operation cancelled</string>
-    <string name="title_profile_list">Profiles</string>
     <string name="title_profile_details">Profile Details</string>
     <string name="profiles">Profiles</string>
     <string name="new_profile_title" type="id">New profile</string>
     <string name="delete_profile">Delete profile</string>
     <string name="delete">Delete</string>
-    <string name="error_invalid_date">Invalid date</string>
     <string name="profile_name_label">Profile name</string>
     <string name="url_label">URL</string>
     <string name="text_welcome">Welcome</string>
     </string-array>
     <string name="posting_permitted">Posting of new transactions enabled</string>
     <string name="profile_subtitle_read_only">(Read only)</string>
-    <string name="crash_report_contents_label">Crash report contents:</string>
     <string name="crash_dialog_title">MoLe crashed</string>
     <string name="btn_send_crash_report">Send…</string>
     <string name="btn_not_now">Not now</string>
     <string name="crash_send_question">Would you like to send the crash report to the developer? This would help diagnosing and fixing the problem. The report is sent via email and you can review it before sending.</string>
     <string name="send_crash_via">Send crash report via:</string>
     <string name="btn_show_report">Show report</string>
-    <string name="color_label">Color</string>
     <string name="profile_list_rearrange_handle_label">Rearrange items handle</string>
-    <string name="btn_ok">OK</string>
-    <string name="btn_cancel">Cancel</string>
     <string name="default_color_btn">Default</string>
     <string name="profile_color_label">Profile color</string>
     <string name="btn_select_label">Select</string>
     <string name="pref_preferred_autocompletion_account_filter_hint">Filter for transaction auto-completion</string>
     <string name="remove_profile_dialog_message">Permanently remove this profile?</string>
     <string name="Remove">Remove</string>
-    <string name="text_loading">Loading…</string>
     <string name="err_invalid_url">Invalid URL</string>
     <string name="btn_color_picker_button">Color picker button</string>
     <string name="insecure_scheme_with_auth">WARNING: Insecure http used with authentication</string>
     <string name="future_dates_365">Up to a year</string>
     <string name="future_dates_all">Without restrictions</string>
     <string name="api_html">Version before 1.14</string>
-    <string name="api_pre_1_15">Version 1.14.x</string>
-    <string name="api_post_1_14">Version 1.15 and above</string>
-    <string name="api_auto">Detect automatically</string>
-    <string name="profile_api_version_title">Backend server version</string>
+    <string name="api_1_14">Version 1.14</string>
+    <string name="api_1_15">Version 1.15</string>
+    <string name="api_auto">Automatic</string>
+    <string name="profile_api_version_title">Protocol version</string>
     <string name="currency_symbol" translatable="false">¤</string>
     <string name="add_button">Add…</string>
     <string name="close_button">Close</string>
     <string name="go_to_date_menu_title">Go to date</string>
     <string name="splash_icon_description">Main app icon</string>
     <string name="sub_accounts_expand_collapse_trigger_description">Sub-accounts expand/collapse trigger</string>
-    <string name="transaction_count_summary">%,d transactions as of %s</string>
-    <string name="account_count_summary">%,d accounts as of %s</string>
-    <string name="api_version_unknown_label">Unknown</string>
-    <string name="api_pre_1_19">Before 1.20.?</string>
-    <string name="detected_version_label">Detected version</string>
+    <string name="transaction_count_summary">%1$,d transactions as of %2$s</string>
+    <string name="account_count_summary">%1$,d accounts as of %2$s</string>
+    <string name="server_version_unknown_label">Unknown</string>
+    <string name="detected_server_pre_1_20_1">Before 1.20.1</string>
+    <string name="new_transaction_fab_description">Plus icon</string>
+    <string name="api_1_19_1">Version 1.19.1</string>
+    <string name="profile_server_version_title">Server version</string>
+    <string name="err_json_parser_error">Error parsing backend JSON response. Perhaps the configured API version doesn\'t match</string>
+    <string name="btn_profile_options">Configure profile</string>
+    <string name="err_json_send_error_head">Error storing transaction on backend server</string>
+    <string name="err_json_send_error_tail">A mismatch in the configured API version could be causing this</string>
+    <string name="err_json_send_error_unsupported">Perhaps the API of the backend server is not supported by MoLe</string>
+    <string name="scan_qr">Scan QR code</string>
+    <string name="nav_templates">Templates</string>
+    <string name="title_activity_templates">Templates</string>
+    <string name="help_menu_item_title">Help</string>
+    <string name="pattern_has_errors">Pattern has errors</string>
+    <string name="account_name_is_empty">Account name missing</string>
+    <string name="pattern_is_empty">Pattern missing</string>
+    <string name="invalid_matching_group_number">Invalid matching group number</string>
+    <string name="template_name_label">Template name</string>
+    <string name="template_details_pattern_label">Pattern</string>
+    <string name="template_details_test_text_label">Test text</string>
+    <string name="template_details_account_name_label">Account name</string>
+    <string name="template_details_account_row_label">Details of account #%d</string>
+    <string name="account_name_source_label">Account name source</string>
+    <string name="template_details_source_literal">literal</string>
+    <string name="account_comment_source_label">Account comment source</string>
+    <string name="account_amount_source_label">Amount source</string>
+    <string name="template_details_account_comment_label">Account comment</string>
+    <string name="template_details_account_amount_label">Amount</string>
+    <string name="choose_template_detail_source_label">Pattern match group</string>
+    <string name="missing_pattern_error">Missing pattern</string>
+    <string name="missing_test_text">Missing test text</string>
+    <string name="pattern_without_groups">Pattern has no capturing groups</string>
+    <string name="pattern_does_not_match">Pattern doesn\'t match the test text</string>
+    <string name="template_transaction_parameters_label">Transaction parameters</string>
+    <string name="template_transaction_description_hint">Transaction description</string>
+    <string name="template_transaction_comment_hint">Transaction comment</string>
+    <string name="transaction_description_source_label">Transaction description source</string>
+    <string name="transaction_comment_source_label">Transaction comment source</string>
+    <string name="template_details_date_label">Transaction date</string>
+    <string name="date_year_hint">year</string>
+    <string name="date_month_hint">month</string>
+    <string name="date_day_hint">date</string>
+    <string name="template_details_date_year_source_label">year</string>
+    <string name="template_details_date_day_source_label">date</string>
+    <string name="month_source_label">month</string>
+    <string name="unnamed_template">Template with no name</string>
+    <string name="add_button_description">Add template</string>
+    <string name="no_template_matches">No template matches</string>
+    <string name="choose_template_to_apply">Choose template to apply</string>
+    <string name="title_edit_template">Edit template</string>
+    <string name="title_new_template">New template</string>
+    <string name="template_xxx_deleted">Template \'%1$s\' deleted</string>
+    <string name="action_undo">Undo</string>
+    <string name="pattern_match_result">Pattern match result</string>
+    <string name="template_item_match_group_source">Group %1$d (%2$s)</string>
+    <string name="template_account_keep_amount_sign">Sign will not be altered</string>
+    <string name="template_account_change_amount_sign">Amount sign will be changed (plus to minus; minus to plus)</string>
+    <string name="template_account_negate_amount_label">Change amount sign</string>
+    <string name="template_is_fallback_label">Fallback template</string>
+    <string name="template_is_fallback_yes">Template will be offered for selection only when no templates match that aren\'t marked as fallback templates</string>
+    <string name="template_is_fallback_no">Template is a primary, high priority one, not a catch-all</string>
+    <string name="fallback_templates_divider">Fallback templates</string>
+    <string name="template_list_help_title">Templates</string>
+    <string name="template_details_template_params_label">Template parameters</string>
+    <string name="template_params_help_description">Show help on template parameters</string>
+    <string name="account_currency_source_label">Commodity source</string>
+    <string name="action_import_export">Backup/Restore</string>
+    <string name="backup_header">Backup</string>
+    <string name="backup_explanation">Exports all profile configuration and templates to a JSON file. this includes passwords in clear text. Tha backup can later be used to restore the settings on a different device or after a device reset. Data about transactions and accounts are not exported. Instead, these will be fetched from the remote backend when the configuration is restored.</string>
+    <string name="backup_button_label">Backup</string>
+    <string name="restore_header">Restore</string>
+    <string name="restore_explanation">Restores all profiles and templates from a previous backup. Entries that already exist are kept without changes. If you want to restore some entry to a previous state remove it first.</string>
+    <string name="restore_button_label">Restore</string>
+    <string name="config_saved">Configuration saved successfully</string>
+    <string name="backups_activity_label">Backup / Restore</string>
+    <string name="config_restored">Configuration restored successfully</string>
+    <string name="no_profile_restore_hint">… or, you may restore from backup</string>
+    <string name="profile_not_available">Profile not available</string>
+    <string name="api_1_23">Version 1.23</string>
+    <string name="accounts_menu_show_zero">Show zero balances</string>
+    <string name="accounts_menu_show_zero_condensed">Zero balances</string>
 </resources>
index d79776549aeb0e49bd96a14514146e118d3aeee4..f6467892b637fe664c9b14990e10839e1d2df286 100644 (file)
@@ -1,5 +1,5 @@
 <!--
-  ~ Copyright © 2020 Damyan Ivanov.
+  ~ Copyright © 2021 Damyan Ivanov.
   ~ This file is part of MoLe.
   ~ MoLe is free software: you can distribute it and/or modify it
   ~ under the term of the GNU General Public License as published by
     <!-- Base application theme. -->
     <!-- base hue: 261.2245° -->
     <!-- target primary color: #935FF2 -->
+    <style name="MoLeMaterialAutoCompleteTextViewStyle" parent="Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense.ExposedDropdownMenu"></style>
+
     <style name="AppTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar">
         <item name="colorOnPrimary">@android:color/white</item>
-        <item name="drawer_background">?android:attr/popupBackground</item>
         <item name="windowActionBar">false</item>
         <item name="windowNoTitle">true</item>
         <item name="textColor">#686868</item>
@@ -30,8 +31,8 @@
         <item name="textInputStyle">
             @style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense
         </item>
-        <item name="colorError">#FFE1E2</item>
-        <item name="colorOnError">#CD1609</item>
+        <item name="colorError">#CD1609</item>
+        <item name="colorOnError">#FFE1E2</item>
         <item name="colorPrimary">#935ff2</item>
         <item name="colorPrimaryTransparent">#00935ff2</item>
         <item name="colorSecondary">#6920ed</item>
         <item name="android:textAlignment">viewEnd</item>
     </style>
 
-    <style name="account_summary_account_entry_table">
-        <item name="android:layout_width">match_parent</item>
-        <item name="android:layout_height">match_parent</item>
-    </style>
-
     <style name="transaction_list_comment">
         <item name="android:textAppearance">@android:style/TextAppearance.Material.Small</item>
         <item name="android:textColor">?commentColor</item>
diff --git a/app/src/main/res/xml/backup_descriptor.xml b/app/src/main/res/xml/backup_descriptor.xml
deleted file mode 100644 (file)
index 93cbcae..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!--
-  ~ Copyright © 2019 Damyan Ivanov.
-  ~ This file is part of MoLe.
-  ~ MoLe is free software: you can distribute it and/or modify it
-  ~ under the term of the GNU General Public License as published by
-  ~ the Free Software Foundation, either version 3 of the License, or
-  ~ (at your opinion), any later version.
-  ~
-  ~ MoLe is distributed in the hope that it will be useful,
-  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
-  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-  ~ GNU General Public License terms for details.
-  ~
-  ~ You should have received a copy of the GNU General Public License
-  ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
-  -->
-
-<full-backup-content>
-    <!-- Exclude specific shared preferences that contain GCM registration Id -->
-</full-backup-content>
index 223e12e8fbcb0f28f1559e621f1ebf197a23dbc2..c1da32275828ef31c0ccca046348a72b5a50e47f 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?><!--
-  ~ Copyright © 2019 Damyan Ivanov.
+  ~ Copyright © 2024 Damyan Ivanov.
   ~ This file is part of MoLe.
   ~ MoLe is free software: you can distribute it and/or modify it
   ~ under the term of the GNU General Public License as published by
   -->
 
 <network-security-config>
-    <base-config cleartextTrafficPermitted="true" />
+    <base-config cleartextTrafficPermitted="true">
+        <trust-anchors>
+            <certificates src="system" />
+            <certificates src="user" />
+        </trust-anchors>
+    </base-config>
 </network-security-config>
\ No newline at end of file
diff --git a/app/src/test/java/net/ktnx/mobileledger/model/MobileLedgerProfileTest.java b/app/src/test/java/net/ktnx/mobileledger/model/MobileLedgerProfileTest.java
deleted file mode 100644 (file)
index edbd5a7..0000000
+++ /dev/null
@@ -1,92 +0,0 @@
-/*
- * Copyright © 2020 Damyan Ivanov.
- * This file is part of MoLe.
- * MoLe is free software: you can distribute it and/or modify it
- * under the term of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your opinion), any later version.
- *
- * MoLe is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License terms for details.
- *
- * You should have received a copy of the GNU General Public License
- * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package net.ktnx.mobileledger.model;
-
-import org.junit.Test;
-import org.junit.internal.ArrayComparisonFailure;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-
-import static org.junit.Assert.assertArrayEquals;
-import static org.junit.Assert.assertSame;
-import static org.junit.Assert.assertThrows;
-
-public class MobileLedgerProfileTest {
-    private List<LedgerAccount> listFromArray(LedgerAccount[] array) {
-        ArrayList<LedgerAccount> result = new ArrayList<>();
-        Collections.addAll(result, array);
-
-        return result;
-    }
-    private void aTest(LedgerAccount[] oldList, LedgerAccount[] newList,
-                       LedgerAccount[] expectedResult) {
-        List<LedgerAccount> result =
-                MobileLedgerProfile.mergeAccountListsFromWeb(listFromArray(oldList),
-                        listFromArray(newList));
-        assertArrayEquals(expectedResult, result.toArray());
-    }
-    private void negTest(LedgerAccount[] oldList, LedgerAccount[] newList,
-                         LedgerAccount[] expectedResult) {
-        List<LedgerAccount> result =
-                MobileLedgerProfile.mergeAccountListsFromWeb(listFromArray(oldList),
-                        listFromArray(newList));
-        assertThrows(ArrayComparisonFailure.class,
-                () -> assertArrayEquals(expectedResult, result.toArray()));
-    }
-    private LedgerAccount[] emptyArray() {
-        return new LedgerAccount[]{};
-    }
-    @Test
-    public void mergeEmptyLists() {
-        aTest(emptyArray(), emptyArray(), emptyArray());
-    }
-    @Test
-    public void mergeIntoEmptyLists() {
-        LedgerAccount acc1 = new LedgerAccount(null, "Acc1", null);
-        aTest(emptyArray(), new LedgerAccount[]{acc1}, new LedgerAccount[]{acc1});
-    }
-    @Test
-    public void mergeEmptyList() {
-        LedgerAccount acc1 = new LedgerAccount(null, "Acc1", null);
-        aTest(new LedgerAccount[]{acc1}, emptyArray(), emptyArray());
-    }
-    @Test
-    public void mergeEqualLists() {
-        LedgerAccount acc1 = new LedgerAccount(null, "Acc1", null);
-        aTest(new LedgerAccount[]{acc1}, new LedgerAccount[]{acc1}, new LedgerAccount[]{acc1});
-    }
-    @Test
-    public void mergeFlags() {
-        LedgerAccount acc1a = new LedgerAccount(null, "Acc1", null);
-        LedgerAccount acc1b = new LedgerAccount(null, "Acc1", null);
-        acc1b.setExpanded(true);
-        acc1b.setAmountsExpanded(true);
-        List<LedgerAccount> merged = MobileLedgerProfile.mergeAccountListsFromWeb(
-                listFromArray(new LedgerAccount[]{acc1a}),
-                listFromArray(new LedgerAccount[]{acc1b}));
-        assertArrayEquals(new LedgerAccount[]{acc1b}, merged.toArray());
-        assertSame(merged.get(0), acc1a);
-        // restore original values, modified by the merge
-        acc1a.setExpanded(false);
-        acc1a.setAmountsExpanded(false);
-        negTest(new LedgerAccount[]{acc1a}, new LedgerAccount[]{acc1b},
-                new LedgerAccount[]{new LedgerAccount(null, "Acc1", null)});
-    }
-}
\ No newline at end of file
index 27bc1207d1aacb12c780e227774127a3f9e25d38..e9d71576e71beaaba39d96d67f402a45e9f63e69 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2020 Damyan Ivanov.
+ * Copyright © 2022 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
@@ -21,10 +21,10 @@ buildscript {
     
     repositories {
         google()
-        jcenter()
+        mavenCentral()
     }
     dependencies {
-        classpath 'com.android.tools.build:gradle:4.0.1'
+        classpath 'com.android.tools.build:gradle:8.0.2'
         
 
         // NOTE: Do not place your application dependencies here; they belong
@@ -35,7 +35,7 @@ buildscript {
 allprojects {
     repositories {
         google()
-        jcenter()
+        mavenCentral()
     }
 }
 
index 048e816edeb7bd5d8292f38228ae641cf4edf448..db96c7725ba87fd8ea2d7a50722e54fe5ebacccf 100644 (file)
@@ -1,5 +1,5 @@
 #
-# Copyright © 2019 Damyan Ivanov.
+# Copyright © 2024 Damyan Ivanov.
 # This file is part of MoLe.
 # MoLe is free software: you can distribute it and/or modify it
 # under the term of the GNU General Public License as published by
 # along with MoLe. If not, see <https://www.gnu.org/licenses/>.
 #
 
-# Project-wide Gradle settings.
-# IDE (e.g. Android Studio) users:
-# Gradle settings configured through the IDE *will override*
-# any settings specified in this file.
-# For more details on how to configure your build environment visit
+## For more details on how to configure your build environment visit
 # http://www.gradle.org/docs/current/userguide/build_environment.html
+#
 # Specifies the JVM arguments used for the daemon process.
 # The setting is particularly useful for tweaking memory settings.
-org.gradle.jvmargs=-Xmx1536m
+# Default value: -Xmx1024m -XX:MaxPermSize=256m
+# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
+#
 # When configured, Gradle will run in incubating parallel mode.
 # This option should only be used with decoupled projects. More details, visit
 # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
 # org.gradle.parallel=true
+#Sun Mar 17 11:29:00 EET 2024
 android.debug.obsoleteApi=true
+android.defaults.buildfeatures.buildconfig=true
+android.enableJetifier=false
+android.enableR8.fullMode=false
+android.nonFinalResIds=false
+android.nonTransitiveRClass=true
 android.useAndroidX=true
-android.enableJetifier=true
\ No newline at end of file
+org.gradle.jvmargs=-Xmx1024M -Dkotlin.daemon.jvm.options\="-Xmx1536M"
+org.gradle.unsafe.configuration-cache=true
index c3af6bcec5d2b1d6b32f28c663aaa579d656855b..855f89cc8328ec83f5f1cf99a034a4c0aa8cd550 100644 (file)
@@ -1,6 +1,21 @@
-#Mon Jun 08 21:33:09 EEST 2020
+#
+# Copyright © 2024 Damyan Ivanov.
+# This file is part of MoLe.
+# MoLe is free software: you can distribute it and/or modify it
+# under the term of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your opinion), any later version.
+#
+# MoLe is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License terms for details.
+#
+# You should have received a copy of the GNU General Public License
+# along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+#
 distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
 zipStoreBase=GRADLE_USER_HOME
 zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
index f9553162f122c71b34635112e717c3e733b5b212..e95643d6a2ca62258464e83c72f5156dc941c609 100644 (file)
@@ -1,84 +1,84 @@
-@if "%DEBUG%" == "" @echo off
-@rem ##########################################################################
-@rem
-@rem  Gradle startup script for Windows
-@rem
-@rem ##########################################################################
-
-@rem Set local scope for the variables with windows NT shell
-if "%OS%"=="Windows_NT" setlocal
-
-set DIRNAME=%~dp0
-if "%DIRNAME%" == "" set DIRNAME=.
-set APP_BASE_NAME=%~n0
-set APP_HOME=%DIRNAME%
-
-@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
-set DEFAULT_JVM_OPTS=
-
-@rem Find java.exe
-if defined JAVA_HOME goto findJavaFromJavaHome
-
-set JAVA_EXE=java.exe
-%JAVA_EXE% -version >NUL 2>&1
-if "%ERRORLEVEL%" == "0" goto init
-
-echo.
-echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
-
-goto fail
-
-:findJavaFromJavaHome
-set JAVA_HOME=%JAVA_HOME:"=%
-set JAVA_EXE=%JAVA_HOME%/bin/java.exe
-
-if exist "%JAVA_EXE%" goto init
-
-echo.
-echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
-
-goto fail
-
-:init
-@rem Get command-line arguments, handling Windows variants
-
-if not "%OS%" == "Windows_NT" goto win9xME_args
-
-:win9xME_args
-@rem Slurp the command line arguments.
-set CMD_LINE_ARGS=
-set _SKIP=2
-
-:win9xME_args_slurp
-if "x%~1" == "x" goto execute
-
-set CMD_LINE_ARGS=%*
-
-:execute
-@rem Setup the command line
-
-set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
-
-@rem Execute Gradle
-"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
-
-:end
-@rem End local scope for the variables with windows NT shell
-if "%ERRORLEVEL%"=="0" goto mainEnd
-
-:fail
-rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
-rem the _cmd.exe /c_ return code!
-if  not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
-exit /b 1
-
-:mainEnd
-if "%OS%"=="Windows_NT" endlocal
-
-:omega
+@if "%DEBUG%" == "" @echo off\r
+@rem ##########################################################################\r
+@rem\r
+@rem  Gradle startup script for Windows\r
+@rem\r
+@rem ##########################################################################\r
+\r
+@rem Set local scope for the variables with windows NT shell\r
+if "%OS%"=="Windows_NT" setlocal\r
+\r
+set DIRNAME=%~dp0\r
+if "%DIRNAME%" == "" set DIRNAME=.\r
+set APP_BASE_NAME=%~n0\r
+set APP_HOME=%DIRNAME%\r
+\r
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.\r
+set DEFAULT_JVM_OPTS=\r
+\r
+@rem Find java.exe\r
+if defined JAVA_HOME goto findJavaFromJavaHome\r
+\r
+set JAVA_EXE=java.exe\r
+%JAVA_EXE% -version >NUL 2>&1\r
+if "%ERRORLEVEL%" == "0" goto init\r
+\r
+echo.\r
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.\r
+echo.\r
+echo Please set the JAVA_HOME variable in your environment to match the\r
+echo location of your Java installation.\r
+\r
+goto fail\r
+\r
+:findJavaFromJavaHome\r
+set JAVA_HOME=%JAVA_HOME:"=%\r
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe\r
+\r
+if exist "%JAVA_EXE%" goto init\r
+\r
+echo.\r
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%\r
+echo.\r
+echo Please set the JAVA_HOME variable in your environment to match the\r
+echo location of your Java installation.\r
+\r
+goto fail\r
+\r
+:init\r
+@rem Get command-line arguments, handling Windows variants\r
+\r
+if not "%OS%" == "Windows_NT" goto win9xME_args\r
+\r
+:win9xME_args\r
+@rem Slurp the command line arguments.\r
+set CMD_LINE_ARGS=\r
+set _SKIP=2\r
+\r
+:win9xME_args_slurp\r
+if "x%~1" == "x" goto execute\r
+\r
+set CMD_LINE_ARGS=%*\r
+\r
+:execute\r
+@rem Setup the command line\r
+\r
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar\r
+\r
+@rem Execute Gradle\r
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%\r
+\r
+:end\r
+@rem End local scope for the variables with windows NT shell\r
+if "%ERRORLEVEL%"=="0" goto mainEnd\r
+\r
+:fail\r
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of\r
+rem the _cmd.exe /c_ return code!\r
+if  not "" == "%GRADLE_EXIT_CONSOLE%" exit 1\r
+exit /b 1\r
+\r
+:mainEnd\r
+if "%OS%"=="Windows_NT" endlocal\r
+\r
+:omega\r
diff --git a/metadata/bg-BG/changelogs/37.txt b/metadata/bg-BG/changelogs/37.txt
new file mode 100644 (file)
index 0000000..62d5605
--- /dev/null
@@ -0,0 +1,10 @@
+
+* НОВО
+    + поддръжка на последната версия на протокола за комуникация (hledger-web 1.19.1)
+    + откриване на версията на сървъра
+    + поддръжка на повече от една версия на протокола за комуникация
+* ПОДОБРЕНО
+    + настройките на базата данни се извършват във фонов режим, докато се показва началния екран
+* ПОПРАВЕНО
+    + използване на валутата по подразбиране при въвеждане на ново движение
+    + отстранени няколко срива
diff --git a/metadata/bg-BG/changelogs/38.txt b/metadata/bg-BG/changelogs/38.txt
new file mode 100644 (file)
index 0000000..41a6cfb
--- /dev/null
@@ -0,0 +1,7 @@
+
+* NEW
+    + макети за движения, прилагани чрез сканиране на QR код
+* IMPROVEMENTS
+    + по-голям бутон за валута при новите движения
+    + унифицирано поведение на хвърчащия бутон за добавяне/съхраняване
+    + частична миграция към напълно асинхронен слой за връзка с базата данни
diff --git a/metadata/bg-BG/changelogs/39.txt b/metadata/bg-BG/changelogs/39.txt
new file mode 100644 (file)
index 0000000..c978964
--- /dev/null
@@ -0,0 +1,3 @@
+
+* ПОПРАВКИ
+    + отстранен проблем при миграцията на данните при профили без проверена версия на сървъра
diff --git a/metadata/bg-BG/changelogs/40.txt b/metadata/bg-BG/changelogs/40.txt
new file mode 100644 (file)
index 0000000..db85b5d
--- /dev/null
@@ -0,0 +1,11 @@
+
+* НОВО
+    + новите движения са видими в списъка с движения без да е нужно презареждане
+* ПОДОБРЕНО
+    + завършена миграция към напълно асинхронен слой за връзка с базата данни
+    + подобрено поведение при превключване от списъка със сметки към списъка с движения за пръв път
+* ПОПРАВЕНО
+    + визуални проблеми в редактора на шаблони
+    + поправена обработка на грешки при изпробване на различни версии на протокола за връзка с hledger-web
+    + без изчистване на датата при зареждане на старо движение
+    + няколко по-малки поправки
diff --git a/metadata/bg-BG/changelogs/41.txt b/metadata/bg-BG/changelogs/41.txt
new file mode 100644 (file)
index 0000000..5ddb0cc
--- /dev/null
@@ -0,0 +1,12 @@
+
+* НОВОСТИ
+    + поддръжка на валута в шаблоните
+    + баланс с натрупване при филтриране на движенията по сметка
+    + текущ баланс при избор на сметка (ново движение)
+* ПОДОБРЕНИЯ
+    + по-отчетлив фон на изскачащия прозорец при избор на сметка при тъмна тема
+    + подобрено оформление на балансите про много дълги имена на сметки
+* ПОПРАВКИ
+    + зачитане на валутата по подразбиране при въвеждане на ново движение
+    + отразяване на промените в текущо активния профил
+    + поправено разнасяне на новите движения по родителските сметки
diff --git a/metadata/bg-BG/changelogs/42.txt b/metadata/bg-BG/changelogs/42.txt
new file mode 100644 (file)
index 0000000..448903a
--- /dev/null
@@ -0,0 +1,4 @@
+* ПОПРАВКИ
+    + отстранен проблем при ново движение и въвеждане на невалидна сума
+    + поправено зареждане на предишно движение по описание (отново)
+    + отстранен срив при разпознаване на версия на hledger, състояща се само от два компонента
diff --git a/metadata/bg-BG/changelogs/43.txt b/metadata/bg-BG/changelogs/43.txt
new file mode 100644 (file)
index 0000000..ffc037b
--- /dev/null
@@ -0,0 +1,2 @@
+* ПОПРАВКИ
+    + поправено намиране на стари движения при въвеждане на описание на ново движение на някои варианти/версии на Андроид (повредено във версия 0.18.0)
diff --git a/metadata/bg-BG/changelogs/44.txt b/metadata/bg-BG/changelogs/44.txt
new file mode 100644 (file)
index 0000000..59b4578
--- /dev/null
@@ -0,0 +1,4 @@
+* НОВО
+    + съхраняване на резервни копия на всички профили и шаблони; възстановяване от резервно копие
+* ПОПРАВКИ
+    + няколко срива свързани със стартиране на екрана за ново двишение от пряк път
diff --git a/metadata/bg-BG/changelogs/45.txt b/metadata/bg-BG/changelogs/45.txt
new file mode 100644 (file)
index 0000000..4d4824f
--- /dev/null
@@ -0,0 +1,6 @@
+* ПОПРАВКИ
+    + Ново движение: преместване на фокуса в полето за сума след избиране на сметка
+    + Ново движение: отстранен срив при връщане в приложението когато няма фокусиран елемент
+    + отстранен срив при обновяване на БД до версия 0.20.0
+    + поправено възстановяване на настройките при налични празни стойности
+    + без използване на AsyncTask -- вече не се препоръчва
diff --git a/metadata/bg-BG/changelogs/46.txt b/metadata/bg-BG/changelogs/46.txt
new file mode 100644 (file)
index 0000000..32845dd
--- /dev/null
@@ -0,0 +1,4 @@
+* НОВО
+    + архивно копие в облака
+* ПОПРАВКИ
+    + два проблема в работата на базата данни, единият причиняващ срив при стартиране
diff --git a/metadata/bg-BG/changelogs/47.txt b/metadata/bg-BG/changelogs/47.txt
new file mode 100644 (file)
index 0000000..ecb69e4
--- /dev/null
@@ -0,0 +1,2 @@
+* ПОПРАВКИ
+    + отстранен още един проблем при обновяване на БД от версия 0.16.0
diff --git a/metadata/bg-BG/changelogs/48.txt b/metadata/bg-BG/changelogs/48.txt
new file mode 100644 (file)
index 0000000..b92f6e0
--- /dev/null
@@ -0,0 +1,4 @@
+* ИЗВЕСТНИ ПРОБЛЕМИ
+    + Несъвместимост с hledger-web 1.23+
+* ПОПРАВКИ
+    + поправено дописване на описанието при въвеждане на ново движение
diff --git a/metadata/bg-BG/changelogs/49.txt b/metadata/bg-BG/changelogs/49.txt
new file mode 100644 (file)
index 0000000..fbb1844
--- /dev/null
@@ -0,0 +1,4 @@
+* НОВО
+    + Добавена поддръжка на hledger-web версия 1.23
+* ПОПРАВКИ
+    + Включване на поддържащ файл за работата на БД, пропъснат във версия 0.20.4
diff --git a/metadata/bg-BG/changelogs/50.txt b/metadata/bg-BG/changelogs/50.txt
new file mode 100644 (file)
index 0000000..bdf0e80
--- /dev/null
@@ -0,0 +1,4 @@
+* ПОПРАВКИ
+    + поддръжане на hledger-web 1.23 и при добавяне на нови движения
+    + поправена натрупана сума при добавяне на движение в миналото
+    + поправен срив при изпращане на ново движение без данни за суми
diff --git a/metadata/bg-BG/changelogs/51.txt b/metadata/bg-BG/changelogs/51.txt
new file mode 100644 (file)
index 0000000..0af9229
--- /dev/null
@@ -0,0 +1,6 @@
+* ПОПРАВКИ
+    + отстранен срив при балансиране на трансакция с повече от една валута
+    + отстранен срив при дублиране на макет
+    + отстранен срив при зареждане на настройки от резервно копие
+* ПОДОБРЕНИЯ
+    + ново движение: включване на полазването на валути при зареждане на предишни движение с валути
diff --git a/metadata/bg-BG/changelogs/52.txt b/metadata/bg-BG/changelogs/52.txt
new file mode 100644 (file)
index 0000000..9c8c8db
--- /dev/null
@@ -0,0 +1,5 @@
+* ПОПРАВКИ
+    + коригирани версии на gradle
+* ДРУГИ
+    + обновени версии на множество библиотеки
+    + прицелване във версия 31 на платформата
diff --git a/metadata/bg-BG/changelogs/53.txt b/metadata/bg-BG/changelogs/53.txt
new file mode 100644 (file)
index 0000000..427dbb3
--- /dev/null
@@ -0,0 +1,4 @@
+* ПОПРАВКИ
+    + отстранен проблем със съвместимостта с hledger-web 1.23+ при изпращане на нови транзакции. Благодарности на Faye Duxovni за поправката!
+    + поправен срив при изтриване на шаблони
+    + поправен рядък срив при изпращане на транзакции, съдържащи повече от една сметка без сума и нулев остатъчен баланс
diff --git a/metadata/bg-BG/changelogs/54.txt b/metadata/bg-BG/changelogs/54.txt
new file mode 100644 (file)
index 0000000..2e1dace
--- /dev/null
@@ -0,0 +1,2 @@
+* ПОПРАВКИ
+    + отстранен проблем с централизираното резервно копие на настройките
diff --git a/metadata/bg-BG/changelogs/55.txt b/metadata/bg-BG/changelogs/55.txt
new file mode 100644 (file)
index 0000000..208db92
--- /dev/null
@@ -0,0 +1,2 @@
+* ПОПРАВКИ
+    + отстранен проблем при изпращане на транзакции към hledger-web 1.23+
diff --git a/metadata/bg-BG/changelogs/56.txt b/metadata/bg-BG/changelogs/56.txt
new file mode 100644 (file)
index 0000000..218ba4a
--- /dev/null
@@ -0,0 +1,4 @@
+* ПОПРАВКИ
+    + Позволяване на потребителски сертификати в настройките за сигурността на мрежовите връзки
+* ДРУГИ
+    + Обновена версия на gradle
index da2beb60242e70a06e556806f05cfb341f278328..b145702319cba17883f1d211bdafecbabcefb055 100644 (file)
@@ -2,11 +2,11 @@
 
 hledger-web е уеб интерфейс за hledger - система за двустранно счетоводство, базирана на текстови файлове.
 
-MoLe (оÑ\82 "Mobile Ledger" - Ð¼Ð¾Ð±Ð¸Ð»ÐµÐ½ Ñ\81Ñ\87еÑ\82оводен Ð¶Ñ\83Ñ\80нал) Ð¿Ñ\80едлага Ð¿Ð¾-еÑ\81Ñ\82еÑ\81Ñ\82вен Ð½Ð°Ñ\87ин Ð½Ð° Ñ\80абоÑ\82а Ñ\81 hledger-web Ð·Ð° мобилни устройства.
+MoLe (оÑ\82 "Mobile Ledger" - Ð¼Ð¾Ð±Ð¸Ð»ÐµÐ½ Ñ\81Ñ\87еÑ\82оводен Ð¶Ñ\83Ñ\80нал) Ð¿Ñ\80едлага Ð¿Ð¾-еÑ\81Ñ\82еÑ\81Ñ\82вен Ð½Ð°Ñ\87ин Ð½Ð° Ñ\80абоÑ\82а Ñ\81 hledger-web Ð¾Ñ\82 мобилни устройства.
 
 Функции:
 
-<ul><li>СпиÑ\81Ñ\8aк Ð½Ð° Ñ\81меÑ\82киÑ\82е Ñ\81 Ñ\82екÑ\83Ñ\89о Ñ\81алдо</li><li>СпиÑ\81Ñ\8aк Ð½Ð° Ð´Ð²Ð¸Ð¶ÐµÐ½Ð¸Ñ\8fÑ\82а Ð¿Ð¾ Ñ\81меÑ\82киÑ\82е Ñ\81 Ñ\84илÑ\82Ñ\8aÑ\80 Ð¿Ð¾ Ð¸Ð¼Ðµ Ð½Ð° Ñ\81меÑ\82ка</li><li>Ð\92Ñ\8aвеждане Ð½Ð° Ð½Ð¾Ð²Ð¾ Ð´Ð²Ð¸Ð¶ÐµÐ½Ð¸Ðµ Ð¿Ð¾ Ñ\81меÑ\82ка</li><li>РабоÑ\82а Ñ\81 Ð²Ð°Ð»Ñ\83Ñ\82и</li><li>Ð\9cножеÑ\81Ñ\82во Ð¸Ð·Ñ\82оÑ\87ниÑ\86и Ð½Ð° Ð´Ð°Ð½Ð½Ð¸, Ð² Ñ\86вÑ\8fÑ\82 Ð¿Ð¾ Ð¶ÐµÐ»Ð°Ð½Ð¸Ðµ</li><li>Ð\98денÑ\82иÑ\84иÑ\86иÑ\80ане Ð¿Ñ\80ед Ð¸Ð·Ñ\82оÑ\87ника Ð½Ð° Ð´Ð°Ð½Ð½Ð¸</li></ul>
+<ul><li>СпиÑ\81Ñ\8aк Ð½Ð° Ñ\81меÑ\82киÑ\82е Ñ\81 Ñ\82екÑ\83Ñ\89о Ñ\81алдо</li><li>СпиÑ\81Ñ\8aк Ð½Ð° Ð´Ð²Ð¸Ð¶ÐµÐ½Ð¸Ñ\8fÑ\82а Ð¿Ð¾ Ñ\81меÑ\82киÑ\82е Ñ\81 Ñ\84илÑ\82Ñ\8aÑ\80 Ð¿Ð¾ Ð¸Ð¼Ðµ Ð½Ð° Ñ\81меÑ\82ка</li><li>Ð\92Ñ\8aвеждане Ð½Ð° Ð½Ð¾Ð²Ð¾ Ð´Ð²Ð¸Ð¶ÐµÐ½Ð¸Ðµ Ð¿Ð¾ Ñ\81меÑ\82ка</li><li>РабоÑ\82а Ñ\81 Ð²Ð°Ð»Ñ\83Ñ\82и</li><li>Ð\97абележки ÐºÑ\8aм Ð´Ð²Ð¸Ð¶ÐµÐ½Ð¸Ñ\8fÑ\82а Ð¿Ð¾ Ñ\81меÑ\82ки Ð¸ ÐºÑ\8aм Ð¾Ñ\82делни Ð¿ÐµÑ\80а</li><li>Ð\9cножеÑ\81Ñ\82во Ð¸Ð·Ñ\82оÑ\87ниÑ\86и Ð½Ð° Ð´Ð°Ð½Ð½Ð¸, Ð² Ñ\86вÑ\8fÑ\82 Ð¿Ð¾ Ð¶ÐµÐ»Ð°Ð½Ð¸Ðµ</li><li>Ð\9cакеÑ\82и Ð½Ð° Ð´Ð²Ð¸Ð¶ÐµÐ½Ð¸Ñ\8f Ð¿Ð¾ Ñ\81меÑ\82ки, Ð°ÐºÑ\82ивиÑ\80ани Ñ\87Ñ\80ез Ñ\81каниÑ\80ане Ð½Ð° QR ÐºÐ¾Ð´</li></ul>
 Приложението е в процес на разработка. Ето някои от планираните функции:
 
-<ul><li>СпÑ\80авки</li><li>Ð\9fовеÑ\87е Ñ\84илÑ\82Ñ\80и Ð½Ð° Ñ\81пиÑ\81Ñ\8aка Ñ\81 Ð´Ð²Ð¸Ð¶ÐµÐ½Ð¸Ñ\8fÑ\82а Ð¿Ð¾ Ñ\81меÑ\82киÑ\82е, Ñ\82Ñ\8aÑ\80Ñ\81ене</li><li>Ð\9fопÑ\8aлване Ð½Ð° Ñ\84оÑ\80мÑ\83лÑ\8fÑ\80а Ð·Ð° Ð½Ð¾Ð²Ð¾ Ð´Ð²Ð¸Ð¶ÐµÐ½Ð¸Ðµ Ð¿Ð¾ Ñ\81меÑ\82ка Ñ\81 Ð´Ð°Ð½Ð½Ð¸ Ð¾Ñ\82 SMS (напÑ\80имеÑ\80 Ñ\81Ñ\8aобÑ\89ение Ð¾Ñ\82 Ð±Ð°Ð½ÐºÐ°Ñ\82а) Ð¸Ð»Ð¸ Ð´Ð²Ñ\83измеÑ\80ен Ð±Ð°Ñ\80код</li></ul>
+<ul><li>СпÑ\80авки</li><li>Ð\9fовеÑ\87е Ñ\84илÑ\82Ñ\80и Ð½Ð° Ñ\81пиÑ\81Ñ\8aка Ñ\81 Ð´Ð²Ð¸Ð¶ÐµÐ½Ð¸Ñ\8fÑ\82а Ð¿Ð¾ Ñ\81меÑ\82киÑ\82е, Ñ\82Ñ\8aÑ\80Ñ\81ене</li><li>Ð\90кÑ\82ивиÑ\80ане Ð½Ð° Ð¼Ð°ÐºÐµÑ\82и Ð½Ð° Ð´Ð²Ð¸Ð¶ÐµÐ½Ð¸Ñ\8f Ð¿Ð¾ Ñ\81меÑ\82ки Ñ\81 Ð´Ð°Ð½Ð½Ð¸ Ð¾Ñ\82 SMS (напÑ\80имеÑ\80 Ñ\81Ñ\8aобÑ\89ение Ð¾Ñ\82 Ð±Ð°Ð½ÐºÐ°Ñ\82а) Ð¸Ð»Ð¸ Ñ\80абоÑ\82ниÑ\8f Ð±Ñ\83Ñ\84еÑ\80</li></ul>
diff --git a/metadata/en-US/changelogs/37.txt b/metadata/en-US/changelogs/37.txt
new file mode 100644 (file)
index 0000000..b4e5a5b
--- /dev/null
@@ -0,0 +1,10 @@
+
+* NEW
+    + add support for latest JSON API (hledger-web 1.19.1)
+    + backend server version detection
+    + backend communication supports multiple JSON API versions
+* IMPROVEMENTS
+    + do database-related initialization in the background while the splash screen is shown
+* FIXES
+    + honour default currency in new transaction entry
+    + several crashes fixed
diff --git a/metadata/en-US/changelogs/38.txt b/metadata/en-US/changelogs/38.txt
new file mode 100644 (file)
index 0000000..d974994
--- /dev/null
@@ -0,0 +1,7 @@
+
+* NEW
+    + transaction templates, applied via QR scan
+* IMPROVEMENTS
+    + bigger commodify button in new transaction screen
+    + unified floating action button behaviour
+    + start migration to a fully asynchronous database layer
diff --git a/metadata/en-US/changelogs/39.txt b/metadata/en-US/changelogs/39.txt
new file mode 100644 (file)
index 0000000..e3d936c
--- /dev/null
@@ -0,0 +1,3 @@
+
+* FIXES
+    + fix a bug in db migration for profiles without detected version
diff --git a/metadata/en-US/changelogs/40.txt b/metadata/en-US/changelogs/40.txt
new file mode 100644 (file)
index 0000000..7d7be37
--- /dev/null
@@ -0,0 +1,11 @@
+
+* NEW
+    + newly added transactions are visible in transaction list without a refresh
+* IMPROVEMENTS
+    + finished migration to fully asynchronous database layer
+    + better responsiveness when switching from the account list to the transaction list for the first time
+* FIXES
+    + fix layout glitches in template editor
+    + fix error handling while trying different JSON API versions
+    + stop resetting the date when an old transaction is loaded
+    + several smaller fixes
diff --git a/metadata/en-US/changelogs/41.txt b/metadata/en-US/changelogs/41.txt
new file mode 100644 (file)
index 0000000..4efed71
--- /dev/null
@@ -0,0 +1,12 @@
+
+* NEW
+    + add commodity support to the templates
+    + display running totals when filtering transaction list by account
+    + show current balance in account chooser (new transactions)
+* IMPROVEMENTS
+    + more prominent background for auto-complete pop-ups in dark mode
+    + better placement of account balances with very long/deep account names
+* FIXES
+    + honor default commodity setting in new transaction screen
+    + honor changes in currently active profile
+    + fix propagation of speculative account updates to parent accounts
diff --git a/metadata/en-US/changelogs/42.txt b/metadata/en-US/changelogs/42.txt
new file mode 100644 (file)
index 0000000..bff8da5
--- /dev/null
@@ -0,0 +1,4 @@
+* FIXES
+    + fix a bug in new transaction screen when an invalid amount is entered
+    + fix loading a previous transaction by description (again)
+    + fix crash when parsing of hledger version with only two components
diff --git a/metadata/en-US/changelogs/43.txt b/metadata/en-US/changelogs/43.txt
new file mode 100644 (file)
index 0000000..5e16021
--- /dev/null
@@ -0,0 +1,2 @@
+* FIXES
+    + fix auto-completion of transaction names with non-ASCII characters on some Android variants/versions (broken in 0.18.0)
diff --git a/metadata/en-US/changelogs/44.txt b/metadata/en-US/changelogs/44.txt
new file mode 100644 (file)
index 0000000..c2c4167
--- /dev/null
@@ -0,0 +1,4 @@
+* NEW
+    + backup/restore of profile/template configuration to a file
+* FIXES
+    + fix a couple of crashes related to starting new transaction via shortcut
diff --git a/metadata/en-US/changelogs/45.txt b/metadata/en-US/changelogs/45.txt
new file mode 100644 (file)
index 0000000..d83be7d
--- /dev/null
@@ -0,0 +1,6 @@
+* FIXES
+    + New transaction: focus amount upon account selection
+    + New transaction: fix a crash when returning to the activity with no focused input field
+    + fix a crash in DB upgrade introduced in v0.20.0
+    + fix config restore with null values
+    + move away from deprecated AsyncTask
diff --git a/metadata/en-US/changelogs/46.txt b/metadata/en-US/changelogs/46.txt
new file mode 100644 (file)
index 0000000..57f40b7
--- /dev/null
@@ -0,0 +1,4 @@
+* NEW
+    + cloud backup
+* FIXES
+    + two database problems fixed, one causing crashes at startup
diff --git a/metadata/en-US/changelogs/47.txt b/metadata/en-US/changelogs/47.txt
new file mode 100644 (file)
index 0000000..28581c0
--- /dev/null
@@ -0,0 +1,2 @@
+* FIXES
+    + another fix to DB migration from v0.16.0
diff --git a/metadata/en-US/changelogs/48.txt b/metadata/en-US/changelogs/48.txt
new file mode 100644 (file)
index 0000000..f0ce53d
--- /dev/null
@@ -0,0 +1,4 @@
+* KNOWN PROBLEMS
+    + Incompatibility with hledger-web 1.23+
+* FIXES
+    + fix auto-completion of transaction description
diff --git a/metadata/en-US/changelogs/49.txt b/metadata/en-US/changelogs/49.txt
new file mode 100644 (file)
index 0000000..422c2ce
--- /dev/null
@@ -0,0 +1,4 @@
+* NEW
+    + Add support for hledger-web 1.23
+* FIXES
+    + Ship database support file missed in v0.20.4
diff --git a/metadata/en-US/changelogs/50.txt b/metadata/en-US/changelogs/50.txt
new file mode 100644 (file)
index 0000000..797fcc1
--- /dev/null
@@ -0,0 +1,4 @@
+* FIXES
+    + add hledger-web 1.23 support when adding transactions too
+    + correct running total when a matching transaction is added in the past
+    + fix crash when sending transaction containing only empty amounts
diff --git a/metadata/en-US/changelogs/51.txt b/metadata/en-US/changelogs/51.txt
new file mode 100644 (file)
index 0000000..379834e
--- /dev/null
@@ -0,0 +1,6 @@
+* FIXES
+    + fix crash when auto-balancing multi currency transaction
+    + fix crash when duplicating template
+    + fix crash when restoring configuration backup
+* IMPROVEMENTS
+    + new transaction: turn on commodity setting when loading previous transaction with commodities
diff --git a/metadata/en-US/changelogs/52.txt b/metadata/en-US/changelogs/52.txt
new file mode 100644 (file)
index 0000000..13f6c20
--- /dev/null
@@ -0,0 +1,6 @@
+* FIXES
+    + sync gradle version requirements
+* OTHERS
+    + bump version of several dependent libraries
+    + bump SDK version to 31
+    + adjust deprecated constructor usage
diff --git a/metadata/en-US/changelogs/53.txt b/metadata/en-US/changelogs/53.txt
new file mode 100644 (file)
index 0000000..8e8601b
--- /dev/null
@@ -0,0 +1,4 @@
+* FIXES
+    + fix compatibility wuth hledger-web 1.23+ when submitting new transactions. Thanks to Faye Duxovni for the patch!
+    + fix a crash when deleting templates
+    + fix a rare crash when submitting transactions with multiple accounts with no amounts with zero remaining balance
diff --git a/metadata/en-US/changelogs/54.txt b/metadata/en-US/changelogs/54.txt
new file mode 100644 (file)
index 0000000..a2e3a20
--- /dev/null
@@ -0,0 +1,2 @@
+* FIXES
+    + fix cloud backup
diff --git a/metadata/en-US/changelogs/55.txt b/metadata/en-US/changelogs/55.txt
new file mode 100644 (file)
index 0000000..3697ab5
--- /dev/null
@@ -0,0 +1,2 @@
+* FIXES:
+    + fixed sending of transactions to hledger-web 1.23+
diff --git a/metadata/en-US/changelogs/56.txt b/metadata/en-US/changelogs/56.txt
new file mode 100644 (file)
index 0000000..9b7d3ac
--- /dev/null
@@ -0,0 +1,4 @@
+* FIXES:
+    + allow user certificates in network security config
+* OTHERS:
+    + bump gradle version
index 7a57254f59f0931b1b64f6348d9f17e8f4aa7ae0..3e9f3c28bc8841097aa84ecfaa7276b41a58d82b 100644 (file)
@@ -6,7 +6,7 @@ MoLe (from "Mobile Ledger") is a convenient front-end to hledger-web, providing
 
 Features:
 
-<ul><li>List of accounts with their current balance</li><li>Transaction list with filters</li><li>Input of new transactions</li><li>Currency/commodity support</li><li>Multiple back-ends, optionally with custom color</li><li>Supports http authentication (basic)</li></ul>
+<ul><li>Hierarchical list of accounts with their current balance</li><li>Transaction list with filters</li><li>Input of new transactions</li><li>Currency/commodity support</li><li>Per-transaction and per-posting comments</li><li>Multiple back-ends, optionally with custom color</li><li>Transaction templates matched by QR code</li></ul>
 The development is still ongoing, here's a list of some of the planned features:
 
-<ul><li>Reports</li><li>More filters for the transaction list, search</li><li>Pre-filling of new transaction input from SMS (e.g. from your bank) or QR-code</li></ul>
+<ul><li>Reports</li><li>More filters for the transaction list, search</li><li>Match transaction templates from clipboard or SMS (e.g. from your bank)</li></ul>