├── .gitignore ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro ├── schemas │ └── com.kssidll.arru.data.database.AppDatabase │ │ ├── 1.json │ │ ├── 2.json │ │ ├── 3.json │ │ ├── 4.json │ │ ├── 5.json │ │ ├── 6.json │ │ └── 7.json └── src │ └── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── java │ └── com │ │ └── kssidll │ │ └── arru │ │ ├── Arru.kt │ │ ├── MainActivity.kt │ │ ├── Navigation.kt │ │ ├── broadcast │ │ └── DataExportServiceStopActionReceiver.kt │ │ ├── data │ │ ├── dao │ │ │ ├── CategoryDao.kt │ │ │ ├── ImportDao.kt │ │ │ ├── ItemDao.kt │ │ │ ├── ProducerDao.kt │ │ │ ├── ProductDao.kt │ │ │ ├── ShopDao.kt │ │ │ ├── TransactionBasketDao.kt │ │ │ └── VariantDao.kt │ │ ├── data │ │ │ ├── DatabaseBackup.kt │ │ │ ├── Item.kt │ │ │ ├── Product.kt │ │ │ ├── ProductCategory.kt │ │ │ ├── ProductProducer.kt │ │ │ ├── ProductVariant.kt │ │ │ ├── Shop.kt │ │ │ └── Transaction.kt │ │ ├── database │ │ │ ├── AppDatabase.kt │ │ │ ├── Export.kt │ │ │ └── Import.kt │ │ ├── paging │ │ │ └── FullItemPagingSource.kt │ │ ├── preference │ │ │ └── AppPreferences.kt │ │ └── repository │ │ │ ├── CategoryRepository.kt │ │ │ ├── CategoryRepositorySource.kt │ │ │ ├── ImportRepository.kt │ │ │ ├── ImportRepositorySource.kt │ │ │ ├── ItemRepository.kt │ │ │ ├── ItemRepositorySource.kt │ │ │ ├── ProducerRepository.kt │ │ │ ├── ProducerRepositorySource.kt │ │ │ ├── ProductRepository.kt │ │ │ ├── ProductRepositorySource.kt │ │ │ ├── ShopRepository.kt │ │ │ ├── ShopRepositorySource.kt │ │ │ ├── TransactionBasketRepository.kt │ │ │ ├── TransactionBasketRepositorySource.kt │ │ │ ├── VariantRepository.kt │ │ │ └── VariantRepositorySource.kt │ │ ├── di │ │ └── module │ │ │ ├── AppModule.kt │ │ │ ├── ChangeDatabaseLocationUseCaseModule.kt │ │ │ ├── DataStoreModule.kt │ │ │ ├── DatabaseModule.kt │ │ │ ├── ExportDataUseCaseModule.kt │ │ │ └── ImportDataUseCaseModule.kt │ │ ├── domain │ │ ├── AppLocale.kt │ │ ├── TimePeriodFlowHandler.kt │ │ ├── data │ │ │ ├── ChartSource.kt │ │ │ ├── Data.kt │ │ │ ├── Field.kt │ │ │ ├── FloatSource.kt │ │ │ ├── FuzzySearchSource.kt │ │ │ ├── IdentitySource.kt │ │ │ ├── NameSource.kt │ │ │ ├── RankSource.kt │ │ │ └── SortSource.kt │ │ ├── usecase │ │ │ ├── ChangeDatabaseLocationUseCase.kt │ │ │ ├── ExportDataUIBlockingUseCase.kt │ │ │ ├── ExportDataWithServiceUseCase.kt │ │ │ └── ImportDataUIBlockingUseCase.kt │ │ └── utils │ │ │ └── Currency.kt │ │ ├── helper │ │ ├── DataGenerators.kt │ │ ├── Locale.kt │ │ ├── NavigationSuiteScaffold.kt │ │ ├── Permission.kt │ │ ├── RegexHelper.kt │ │ ├── StringHelper.kt │ │ └── Uri.kt │ │ ├── service │ │ ├── DataExportService.kt │ │ └── State.kt │ │ └── ui │ │ ├── component │ │ ├── SpendingSummaryComponent.kt │ │ ├── TotalAverageAndMedianSpendingComponent.kt │ │ ├── chart │ │ │ ├── Marker.kt │ │ │ ├── OneDimensionalColumnChart.kt │ │ │ └── ShopPriceCompareChart.kt │ │ ├── dialog │ │ │ ├── DeleteWarningConfirmDialog.kt │ │ │ ├── FuzzySearchableListDialog.kt │ │ │ └── MergeConfirmDialog.kt │ │ ├── field │ │ │ ├── SearchField.kt │ │ │ └── StyledOutlinedTextField.kt │ │ ├── list │ │ │ ├── BaseClickableListItem.kt │ │ │ ├── FullItemCard.kt │ │ │ ├── FullItemListContent.kt │ │ │ ├── RankingList.kt │ │ │ ├── SpendingComparisonList.kt │ │ │ └── TransactionBasketCard.kt │ │ └── other │ │ │ ├── Loading.kt │ │ │ ├── ProgressBar.kt │ │ │ └── SecondaryAppBar.kt │ │ ├── screen │ │ ├── backups │ │ │ ├── BackupsRoute.kt │ │ │ ├── BackupsScreen.kt │ │ │ └── BackupsViewModel.kt │ │ ├── display │ │ │ ├── category │ │ │ │ ├── CategoryRoute.kt │ │ │ │ ├── CategoryScreen.kt │ │ │ │ └── CategoryViewModel.kt │ │ │ ├── producer │ │ │ │ ├── ProducerRoute.kt │ │ │ │ ├── ProducerScreen.kt │ │ │ │ └── ProducerViewModel.kt │ │ │ ├── product │ │ │ │ ├── ProductRoute.kt │ │ │ │ ├── ProductScreen.kt │ │ │ │ └── ProductViewModel.kt │ │ │ ├── shop │ │ │ │ ├── ShopRoute.kt │ │ │ │ ├── ShopScreen.kt │ │ │ │ └── ShopViewModel.kt │ │ │ └── transaction │ │ │ │ ├── TransactionRoute.kt │ │ │ │ ├── TransactionScreen.kt │ │ │ │ └── TransactionViewModel.kt │ │ ├── home │ │ │ ├── AnalysisScreen.kt │ │ │ ├── DashboardScreen.kt │ │ │ ├── HomeRoute.kt │ │ │ ├── HomeScreen.kt │ │ │ ├── HomeViewModel.kt │ │ │ ├── TransactionsScreen.kt │ │ │ └── component │ │ │ │ ├── AnalysisDateHeader.kt │ │ │ │ └── HomeScreenNothingToDisplayOverlay.kt │ │ ├── modify │ │ │ ├── ModifyScreen.kt │ │ │ ├── category │ │ │ │ ├── ModifyCategoryScreenImpl.kt │ │ │ │ ├── ModifyCategoryViewModel.kt │ │ │ │ ├── addcategory │ │ │ │ │ ├── AddCategoryRoute.kt │ │ │ │ │ └── AddCategoryViewModel.kt │ │ │ │ └── editcategory │ │ │ │ │ ├── EditCategoryRoute.kt │ │ │ │ │ └── EditCategoryViewModel.kt │ │ │ ├── item │ │ │ │ ├── ModifyItemScreenImpl.kt │ │ │ │ ├── ModifyItemViewModel.kt │ │ │ │ ├── additem │ │ │ │ │ ├── AddItemRoute.kt │ │ │ │ │ └── AddItemViewModel.kt │ │ │ │ └── edititem │ │ │ │ │ ├── EditItemRoute.kt │ │ │ │ │ └── EditItemViewModel.kt │ │ │ ├── producer │ │ │ │ ├── ModifyProducerScreenImpl.kt │ │ │ │ ├── ModifyProducerViewModel.kt │ │ │ │ ├── addproducer │ │ │ │ │ ├── AddProducerRoute.kt │ │ │ │ │ └── AddProducerViewModel.kt │ │ │ │ └── editproducer │ │ │ │ │ ├── EditProducerRoute.kt │ │ │ │ │ └── EditProducerViewModel.kt │ │ │ ├── product │ │ │ │ ├── ModifyProductScreenImpl.kt │ │ │ │ ├── ModifyProductViewModel.kt │ │ │ │ ├── addproduct │ │ │ │ │ ├── AddProductRoute.kt │ │ │ │ │ └── AddProductViewModel.kt │ │ │ │ └── editproduct │ │ │ │ │ ├── EditProductRoute.kt │ │ │ │ │ └── EditProductViewModel.kt │ │ │ ├── shop │ │ │ │ ├── ModifyShopScreenImpl.kt │ │ │ │ ├── ModifyShopViewModel.kt │ │ │ │ ├── addshop │ │ │ │ │ ├── AddShopRoute.kt │ │ │ │ │ └── AddShopViewModel.kt │ │ │ │ └── editshop │ │ │ │ │ ├── EditShopRoute.kt │ │ │ │ │ └── EditShopViewModel.kt │ │ │ ├── transaction │ │ │ │ ├── ModifyTransactionScreenImpl.kt │ │ │ │ ├── ModifyTransactionViewModel.kt │ │ │ │ ├── addtransaction │ │ │ │ │ ├── AddTransactionRoute.kt │ │ │ │ │ └── AddTransactionViewModel.kt │ │ │ │ └── edittransaction │ │ │ │ │ ├── EditTransactionRoute.kt │ │ │ │ │ └── EditTransactionViewModel.kt │ │ │ └── variant │ │ │ │ ├── ModifyVariantScreenImpl.kt │ │ │ │ ├── ModifyVariantViewModel.kt │ │ │ │ ├── addvariant │ │ │ │ ├── AddVariantRoute.kt │ │ │ │ └── AddVariantViewModel.kt │ │ │ │ └── editvariant │ │ │ │ ├── EditVariantRoute.kt │ │ │ │ └── EditVariantViewModel.kt │ │ ├── ranking │ │ │ ├── RankingScreen.kt │ │ │ ├── categoryranking │ │ │ │ ├── CategoryRankingRoute.kt │ │ │ │ └── CategoryRankingViewModel.kt │ │ │ └── shopranking │ │ │ │ ├── ShopRankingRoute.kt │ │ │ │ └── ShopRankingViewModel.kt │ │ ├── search │ │ │ ├── SearchRoute.kt │ │ │ ├── SearchScreen.kt │ │ │ ├── SearchViewModel.kt │ │ │ ├── categorylist │ │ │ │ ├── CategoryListRoute.kt │ │ │ │ └── CategoryListViewModel.kt │ │ │ ├── component │ │ │ │ └── SearchItem.kt │ │ │ ├── producerlist │ │ │ │ ├── ProducerListRoute.kt │ │ │ │ └── ProducerListViewModel.kt │ │ │ ├── productlist │ │ │ │ ├── ProductListRoute.kt │ │ │ │ └── ProductListViewModel.kt │ │ │ ├── shared │ │ │ │ └── SearchList.kt │ │ │ ├── shoplist │ │ │ │ ├── ShopListRoute.kt │ │ │ │ └── ShopListViewModel.kt │ │ │ └── start │ │ │ │ ├── StartRoute.kt │ │ │ │ └── StartScreen.kt │ │ ├── settings │ │ │ ├── SettingsRoute.kt │ │ │ ├── SettingsScreen.kt │ │ │ ├── SettingsViewModel.kt │ │ │ └── component │ │ │ │ ├── LanguageExposedDropdown.kt │ │ │ │ └── ThemeExposedDropdown.kt │ │ └── spendingcomparison │ │ │ ├── SpendingComparisonScreen.kt │ │ │ ├── categoryspendingcomparison │ │ │ ├── CategorySpendingComparisonRoute.kt │ │ │ └── CategorySpendingComparisonViewModel.kt │ │ │ └── shopspendingcomparison │ │ │ ├── ShopSpendingComparisonRoute.kt │ │ │ └── ShopSpendingComparisonViewModel.kt │ │ └── theme │ │ ├── Theme.kt │ │ ├── Type.kt │ │ └── schema │ │ ├── Dark.kt │ │ └── Light.kt │ └── res │ ├── drawable │ ├── close.xml │ ├── ic_launcher_foreground.xml │ └── ic_launcher_monochrome.xml │ ├── mipmap-anydpi-v26 │ ├── ic_launcher.xml │ └── ic_launcher_round.xml │ ├── mipmap-hdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-mdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xxhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xxxhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── resources.properties │ ├── values-en │ └── strings.xml │ ├── values-notnight-v29 │ └── themes.xml │ ├── values-pl │ └── strings.xml │ ├── values-tr │ └── strings.xml │ ├── values │ ├── ic_launcher_background.xml │ ├── strings.xml │ └── themes.xml │ └── xml │ ├── backup_rules.xml │ └── data_extraction_rules.xml ├── build.gradle.kts ├── crowdin.yml ├── docs └── export.md ├── fastlane └── metadata │ └── android │ └── en-US │ ├── changelogs │ ├── 37.txt │ ├── 38.txt │ ├── 39.txt │ ├── 40.txt │ ├── 41.txt │ ├── 42.txt │ ├── 43.txt │ └── 44.txt │ ├── full_description.txt │ ├── images │ ├── icon.png │ └── phoneScreenshots │ │ ├── 01-dashboard.jpg │ │ ├── 02-analysis.jpg │ │ ├── 03-transactions.jpg │ │ ├── 04-product_top.jpg │ │ ├── 05-categories_ranking.jpg │ │ ├── 06-merge.jpg │ │ ├── 07-backups.jpg │ │ ├── 08-transaction_add_item.jpg │ │ ├── 09-transaction_add_select.jpg │ │ └── 10-transaction_add.jpg │ └── short_description.txt ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── images ├── IzzyOnDroidButton.svg ├── analysis.jpg ├── backups.jpg ├── categories_ranking.jpg ├── dashboard.jpg ├── merge.jpg ├── product_top.jpg ├── transaction_add.jpg ├── transaction_add_item.jpg ├── transaction_add_select.jpg └── transactions.jpg ├── processor ├── .gitignore ├── build.gradle.kts └── src │ └── main │ ├── java │ └── com │ │ └── kssidll │ │ └── processor │ │ ├── CurrencyLocale.kt │ │ └── CurrencyLocaleProvider.kt │ └── resources │ └── META-INF │ └── services │ └── com.google.devtools.ksp.processing.SymbolProcessorProvider └── settings.gradle.kts /.gitignore: -------------------------------------------------------------------------------- 1 | # Gradle files 2 | .gradle/ 3 | build/ 4 | 5 | # Local configuration file (sdk path, etc) 6 | local.properties 7 | signing.properties 8 | 9 | # Log/OS Files 10 | *.log 11 | 12 | # Android Studio generated files and folders 13 | captures/ 14 | .externalNativeBuild/ 15 | .cxx/ 16 | *.apk 17 | output.json 18 | /app/release 19 | .kotlin 20 | 21 | # IntelliJ 22 | *.iml 23 | .idea/ 24 | misc.xml 25 | deploymentTargetDropDown.xml 26 | render.experimental.xml 27 | 28 | # Google Services (e.g. APIs or Firebase) 29 | google-services.json 30 | 31 | # Android Profiling 32 | *.hprof 33 | 34 | /app/src/main/assets/database/arrugarq-test.db 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The Clear BSD License 2 | 3 | Copyright (c) 2024 Szymon Kolasa 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted (subject to the limitations in the disclaimer 8 | below) provided that the following conditions are met: 9 | 10 | * Redistributions of source code must retain the above copyright notice, 11 | this list of conditions and the following disclaimer. 12 | 13 | * Redistributions in binary form must reproduce the above copyright 14 | notice, this list of conditions and the following disclaimer in the 15 | documentation and/or other materials provided with the distribution. 16 | 17 | * Neither the name of the copyright holder nor the names of its 18 | contributors may be used to endorse or promote products derived from this 19 | software without specific prior written permission. 20 | 21 | NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY 22 | THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND 23 | CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 24 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 25 | PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 26 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 27 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 28 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR 29 | BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER 30 | IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 31 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 32 | POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Arru

2 |

Your expenses tracker

3 | 4 |
5 | 6 |

7 | 8 | 9 | 10 | 11 |

12 | 13 |

14 | 15 | 16 | 17 | 18 | IzzyOnDroid 19 | 20 |

21 | 22 |

Arru is an app for expenditure tracking/analysis

23 | 24 |

25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 |

36 | 37 |
38 | 39 |

40 | 41 | IzzyOnDroid 42 | 43 |

44 | 45 | # Features 46 | 47 | - Light/Dark mode 48 | - Wide screen support 49 | - Local backups 50 | - Data Export ([documentation](docs/export.md)) 51 | - Transaction baskets tracking your total expenditure with optional product, category, shop and producer spending tracking 52 | - Comparisons between prices at different shops 53 | - Ranking of categories and shops based on total money spent 54 | - Merging capabilities for categories, shops, products and producers 55 | 56 | # Tech Stack & Libraries 57 | 58 | - [Kotlin](https://kotlinlang.org/) based 59 | 60 | - [Coroutines](https://github.com/Kotlin/kotlinx.coroutines) for asynchronous computing 61 | 62 | - [Flow](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/) to emit values from data layer reactively 63 | 64 | - [Hilt](https://dagger.dev/hilt/) for dependency injection 65 | 66 | - [Compose Navigation Reimagined](https://github.com/olshevski/compose-navigation-reimagined) for animated navigation 67 | 68 | - [Vico Compose](https://github.com/patrykandpatrick/vico) for graphs 69 | 70 | - [Fuzzywuzzy](https://github.com/xdrop/fuzzywuzzy) for fuzzy searching capabilities 71 | 72 | - Jetpack 73 | 74 | - [Compose](https://developer.android.com/jetpack/compose) - Modern Declarative UI style framework based on composable functions 75 | 76 | - [Room](https://developer.android.com/jetpack/androidx/releases/room) - Persistence library providing abstraction layer over SQLite 77 | 78 | - [Material You Kit](https://developer.android.com/jetpack/androidx/releases/compose-material3) - Material 3 powerful UI components 79 | 80 | - [ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel) - Manages UI-related data holder and lifecycle awareness. Allows data to survive configuration changes such as screen rotations 81 | 82 | - [Lifecycle](https://developer.android.com/jetpack/androidx/releases/lifecycle) - Observe Android lifecycles and handle UI states upon the lifecycle changes 83 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle.kts. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 10 | 14 | 18 | 19 | 30 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 45 | 49 | 50 | 51 | 57 | 58 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/Arru.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.content.res.Configuration.UI_MODE_NIGHT_NO 7 | import android.content.res.Configuration.UI_MODE_NIGHT_YES 8 | import androidx.compose.ui.tooling.preview.Devices 9 | import androidx.compose.ui.tooling.preview.Preview 10 | import dagger.hilt.android.HiltAndroidApp 11 | import kotlin.system.exitProcess 12 | 13 | @HiltAndroidApp 14 | class Arru: Application() { 15 | 16 | companion object { 17 | /** 18 | * restarts the app 19 | * @param context app context 20 | */ 21 | fun restart(context: Context) { 22 | val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) 23 | intent!!.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) 24 | context.startActivity(intent) 25 | exitProcess(0) 26 | } 27 | } 28 | } 29 | 30 | const val DAY_IN_MILIS: Long = 86400000 31 | 32 | const val APPLICATION_NAME = "Arru" 33 | 34 | @Preview( 35 | group = "Expanded", 36 | name = "Dark", 37 | showBackground = true, 38 | uiMode = UI_MODE_NIGHT_YES, 39 | device = Devices.PIXEL_FOLD 40 | ) 41 | @Preview( 42 | group = "Expanded", 43 | name = "Light", 44 | showBackground = true, 45 | uiMode = UI_MODE_NIGHT_NO, 46 | device = Devices.PIXEL_FOLD 47 | ) 48 | annotation class PreviewExpanded -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/broadcast/DataExportServiceStopActionReceiver.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.broadcast 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.BroadcastReceiver 5 | import android.content.Context 6 | import android.content.Intent 7 | import android.util.Log 8 | import com.kssidll.arru.service.DataExportService 9 | 10 | /** 11 | * Receiver that stops the [DataExportService] 12 | */ 13 | class DataExportServiceStopActionReceiver: BroadcastReceiver() { 14 | @SuppressLint("LongLogTag") 15 | override fun onReceive( 16 | context: Context?, 17 | intent: Intent? 18 | ) { 19 | val forced = intent?.extras?.getBoolean(DataExportService.FORCED_STOP_KEY) 20 | 21 | Log.d( 22 | TAG, 23 | "onReceive: received with forced = $forced" 24 | ) 25 | 26 | context?.let { 27 | DataExportService.stop( 28 | it, 29 | forced ?: false 30 | ) 31 | } 32 | } 33 | 34 | companion object { 35 | const val TAG = "EXPORT_SERVICE_STOP_ACTION" 36 | } 37 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/data/dao/ImportDao.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.data.dao 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Insert 5 | import androidx.room.OnConflictStrategy 6 | import androidx.room.Query 7 | import androidx.room.Transaction 8 | import com.kssidll.arru.data.data.Item 9 | import com.kssidll.arru.data.data.Product 10 | import com.kssidll.arru.data.data.ProductCategory 11 | import com.kssidll.arru.data.data.ProductProducer 12 | import com.kssidll.arru.data.data.ProductVariant 13 | import com.kssidll.arru.data.data.Shop 14 | import com.kssidll.arru.data.data.TransactionBasket 15 | 16 | @Dao 17 | interface ImportDao { 18 | 19 | @Insert(onConflict = OnConflictStrategy.REPLACE) 20 | suspend fun insertShops(entities: List) 21 | 22 | @Query("DELETE FROM Shop") 23 | suspend fun deleteShops() 24 | 25 | 26 | @Insert(onConflict = OnConflictStrategy.REPLACE) 27 | suspend fun insertProducers(entities: List) 28 | 29 | @Query("DELETE FROM ProductProducer") 30 | suspend fun deleteProducers() 31 | 32 | 33 | @Insert(onConflict = OnConflictStrategy.REPLACE) 34 | suspend fun insertCategories(entities: List) 35 | 36 | @Query("DELETE FROM ProductCategory") 37 | suspend fun deleteCategories() 38 | 39 | 40 | @Insert(onConflict = OnConflictStrategy.REPLACE) 41 | suspend fun insertTransactions(entities: List) 42 | 43 | @Query("DELETE FROM TransactionBasket") 44 | suspend fun deleteTransactions() 45 | 46 | 47 | @Insert(onConflict = OnConflictStrategy.REPLACE) 48 | suspend fun insertProducts(entities: List) 49 | 50 | @Query("DELETE FROM Product") 51 | suspend fun deleteProducts() 52 | 53 | 54 | @Insert(onConflict = OnConflictStrategy.REPLACE) 55 | suspend fun insertVariants(entities: List) 56 | 57 | @Query("DELETE FROM ProductVariant") 58 | suspend fun deleteVariants() 59 | 60 | 61 | @Insert(onConflict = OnConflictStrategy.REPLACE) 62 | suspend fun insertItems(entities: List) 63 | 64 | @Query("DELETE FROM Item") 65 | suspend fun deleteItems() 66 | 67 | 68 | @Transaction 69 | suspend fun deleteAll() { 70 | deleteItems() 71 | deleteVariants() 72 | deleteProducts() 73 | deleteTransactions() 74 | deleteCategories() 75 | deleteProducers() 76 | deleteShops() 77 | } 78 | 79 | @Transaction 80 | suspend fun insertAll( 81 | shops: List, 82 | producers: List, 83 | categories: List, 84 | transactions: List, 85 | products: List, 86 | variants: List, 87 | items: List 88 | ) { 89 | deleteAll() 90 | 91 | insertShops(shops) 92 | insertProducers(producers) 93 | insertCategories(categories) 94 | insertTransactions(transactions) 95 | insertProducts(products) 96 | insertVariants(variants) 97 | insertItems(items) 98 | } 99 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/data/dao/ItemDao.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.data.dao 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Delete 5 | import androidx.room.Insert 6 | import androidx.room.Query 7 | import androidx.room.Update 8 | import com.kssidll.arru.data.data.Item 9 | import com.kssidll.arru.data.data.Product 10 | import com.kssidll.arru.data.data.ProductVariant 11 | import com.kssidll.arru.data.data.TransactionBasket 12 | import kotlinx.coroutines.flow.Flow 13 | 14 | @Dao 15 | interface ItemDao { 16 | // Create 17 | 18 | @Insert 19 | suspend fun insert(item: Item): Long 20 | 21 | // Update 22 | 23 | @Update 24 | suspend fun update(item: Item) 25 | 26 | // Delete 27 | 28 | @Delete 29 | suspend fun delete(item: Item) 30 | 31 | // Helper 32 | 33 | @Query("SELECT transactionbasket.* FROM transactionbasket WHERE transactionbasket.id = :transactionId") 34 | suspend fun getTransactionBasket(transactionId: Long): TransactionBasket? 35 | 36 | @Query("SELECT product.* FROM product WHERE product.id = :productId") 37 | suspend fun getProduct(productId: Long): Product? 38 | 39 | @Query("SELECT productvariant.* FROM productvariant WHERE productvariant.id = :variantId") 40 | suspend fun getVariant(variantId: Long): ProductVariant? 41 | 42 | // Read 43 | 44 | @Query("SELECT item.* FROM item WHERE item.id = :itemId") 45 | suspend fun get(itemId: Long): Item? 46 | 47 | @Query("SELECT item.* FROM item ORDER BY id DESC LIMIT 1") 48 | suspend fun newest(): Item? 49 | 50 | @Query("SELECT item.* FROM item ORDER BY id DESC LIMIT 1") 51 | fun newestFlow(): Flow 52 | 53 | @Query("SELECT COUNT(*) FROM item") 54 | suspend fun totalCount(): Int 55 | 56 | @Query("SELECT item.* FROM item ORDER BY id LIMIT :limit OFFSET :offset") 57 | suspend fun getPagedList( 58 | limit: Int, 59 | offset: Int 60 | ): List 61 | 62 | @Query("SELECT item.* FROM item WHERE item.transactionBasketId = :transactionId") 63 | suspend fun getByTransaction(transactionId: Long): List 64 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/data/dao/VariantDao.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.data.dao 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Delete 5 | import androidx.room.Insert 6 | import androidx.room.Query 7 | import androidx.room.Update 8 | import com.kssidll.arru.data.data.Item 9 | import com.kssidll.arru.data.data.Product 10 | import com.kssidll.arru.data.data.ProductVariant 11 | import kotlinx.coroutines.flow.Flow 12 | 13 | @Dao 14 | interface VariantDao { 15 | // Create 16 | 17 | @Insert 18 | suspend fun insert(variant: ProductVariant): Long 19 | 20 | // Update 21 | 22 | @Update 23 | suspend fun update(variant: ProductVariant) 24 | 25 | // Delete 26 | 27 | @Delete 28 | suspend fun delete(variant: ProductVariant) 29 | 30 | // Helper 31 | 32 | @Query("SELECT product.* FROM product WHERE product.id = :productId") 33 | suspend fun getProduct(productId: Long): Product? 34 | 35 | @Query("SELECT item.* FROM item WHERE item.variantId = :variantId") 36 | suspend fun getItems(variantId: Long): List 37 | 38 | @Delete 39 | suspend fun deleteItems(items: List) 40 | 41 | // Read 42 | 43 | @Query("SELECT productvariant.* FROM productvariant WHERE productvariant.id = :variantId") 44 | suspend fun get(variantId: Long): ProductVariant? 45 | 46 | @Query("SELECT productvariant.* FROM productvariant WHERE productvariant.id = :variantId") 47 | fun getFlow(variantId: Long): Flow 48 | 49 | @Query("SELECT productvariant.* FROM productvariant WHERE productvariant.productId = :productId AND productvariant.name = :name") 50 | suspend fun byProductAndName( 51 | productId: Long, 52 | name: String 53 | ): ProductVariant? 54 | 55 | @Query("SELECT productvariant.* FROM productvariant WHERE productvariant.productId == :productId") 56 | fun byProductFlow(productId: Long): Flow> 57 | 58 | @Query("SELECT COUNT(*) FROM productvariant") 59 | suspend fun totalCount(): Int 60 | 61 | @Query("SELECT productvariant.* FROM productvariant ORDER BY id LIMIT :limit OFFSET :offset") 62 | suspend fun getPagedList( 63 | limit: Int, 64 | offset: Int 65 | ): List 66 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/data/data/ProductProducer.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.data.data 2 | 3 | import androidx.room.Entity 4 | import androidx.room.Ignore 5 | import androidx.room.Index 6 | import androidx.room.PrimaryKey 7 | import com.kssidll.arru.domain.data.FuzzySearchSource 8 | import com.kssidll.arru.domain.data.NameSource 9 | import com.kssidll.arru.helper.generateRandomStringValue 10 | import me.xdrop.fuzzywuzzy.FuzzySearch 11 | 12 | @Entity( 13 | indices = [ 14 | Index( 15 | value = ["name"], 16 | unique = true 17 | ) 18 | ] 19 | ) 20 | data class ProductProducer( 21 | @PrimaryKey(autoGenerate = true) val id: Long, 22 | var name: String, 23 | ): FuzzySearchSource, NameSource { 24 | /** 25 | * Converts the [ProductProducer] data to a string with csv format 26 | * 27 | * Doesn't include the csv headers 28 | * @return [ProductProducer] data as [String] with csv format 29 | */ 30 | @Ignore 31 | fun formatAsCsvString(): String { 32 | return "${id};${name}" 33 | } 34 | 35 | companion object { 36 | /** 37 | * Returns the [String] representing the [ProductProducer] csv format headers 38 | * @return [String] representing the [ProductProducer] csv format headers 39 | */ 40 | @Ignore 41 | fun csvHeaders(): String { 42 | return "id;name" 43 | } 44 | 45 | @Ignore 46 | fun generate(producerId: Long = 0): ProductProducer { 47 | return ProductProducer( 48 | id = producerId, 49 | name = generateRandomStringValue(), 50 | ) 51 | } 52 | 53 | @Ignore 54 | fun generateList(amount: Int = 10): List { 55 | return List(amount) { 56 | generate(it.toLong()) 57 | } 58 | } 59 | } 60 | 61 | @Ignore 62 | constructor( 63 | name: String, 64 | ): this( 65 | 0, 66 | name.trim() 67 | ) 68 | 69 | @Ignore 70 | override fun fuzzyScore(query: String): Int { 71 | return FuzzySearch.extractOne( 72 | query, 73 | listOf(name) 74 | ).score 75 | } 76 | 77 | @Ignore 78 | override fun name(): String { 79 | return name 80 | } 81 | 82 | /** 83 | * @return true if name is valid, false otherwise 84 | */ 85 | @Ignore 86 | fun validName(): Boolean { 87 | return name.isNotBlank() 88 | } 89 | 90 | } 91 | 92 | /** 93 | * Converts a list of [ProductProducer] data to a list of strings with csv format 94 | * @param includeHeaders whether to include the csv headers 95 | * @return [ProductProducer] data as list of string with csv format 96 | */ 97 | fun List.asCsvList(includeHeaders: Boolean = false): List = buildList { 98 | // Add headers 99 | if (includeHeaders) { 100 | add(ProductProducer.csvHeaders() + "\n") 101 | } 102 | 103 | // Add rows 104 | this@asCsvList.forEach { 105 | add(it.formatAsCsvString() + "\n") 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/data/data/ProductVariant.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.data.data 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import androidx.room.ForeignKey 6 | import androidx.room.Ignore 7 | import androidx.room.PrimaryKey 8 | import com.kssidll.arru.domain.data.FuzzySearchSource 9 | import com.kssidll.arru.helper.generateRandomLongValue 10 | import com.kssidll.arru.helper.generateRandomStringValue 11 | import me.xdrop.fuzzywuzzy.FuzzySearch 12 | 13 | @Entity( 14 | foreignKeys = [ 15 | ForeignKey( 16 | entity = Product::class, 17 | parentColumns = ["id"], 18 | childColumns = ["productId"], 19 | onDelete = ForeignKey.RESTRICT, 20 | onUpdate = ForeignKey.RESTRICT, 21 | ), 22 | ] 23 | ) 24 | data class ProductVariant( 25 | @PrimaryKey(autoGenerate = true) val id: Long, 26 | @ColumnInfo(index = true) var productId: Long, 27 | var name: String, 28 | ): FuzzySearchSource { 29 | 30 | @Ignore 31 | constructor( 32 | productId: Long, 33 | name: String, 34 | ): this( 35 | 0, 36 | productId, 37 | name.trim() 38 | ) 39 | 40 | /** 41 | * Converts the [ProductVariant] data to a string with csv format 42 | * 43 | * Doesn't include the csv headers 44 | * @return [ProductVariant] data as [String] with csv format 45 | */ 46 | @Ignore 47 | fun formatAsCsvString(): String { 48 | return "${id};${productId};${name}" 49 | } 50 | 51 | 52 | companion object { 53 | /** 54 | * Returns the [String] representing the [ProductVariant] csv format headers 55 | * @return [String] representing the [ProductVariant] csv format headers 56 | */ 57 | @Ignore 58 | fun csvHeaders(): String { 59 | return "id;productId;name" 60 | } 61 | 62 | @Ignore 63 | fun generate(variantId: Long = 0): ProductVariant { 64 | return ProductVariant( 65 | id = variantId, 66 | productId = generateRandomLongValue(), 67 | name = generateRandomStringValue(), 68 | ) 69 | } 70 | 71 | @Ignore 72 | fun generateList(amount: Int = 10): List { 73 | return List(amount) { 74 | generate(it.toLong()) 75 | } 76 | } 77 | } 78 | 79 | @Ignore 80 | override fun fuzzyScore(query: String): Int { 81 | return FuzzySearch.extractOne( 82 | query, 83 | listOf(name) 84 | ).score 85 | } 86 | 87 | /** 88 | * @return true if name is valid, false otherwise 89 | */ 90 | @Ignore 91 | fun validName(): Boolean { 92 | return name.isNotBlank() 93 | } 94 | } 95 | 96 | /** 97 | * Converts a list of [ProductVariant] data to a list of strings with csv format 98 | * @param includeHeaders whether to include the csv headers 99 | * @return [ProductVariant] data as list of string with csv format 100 | */ 101 | fun List.asCsvList(includeHeaders: Boolean = false): List = buildList { 102 | // Add headers 103 | if (includeHeaders) { 104 | add(ProductVariant.csvHeaders() + "\n") 105 | } 106 | 107 | // Add rows 108 | this@asCsvList.forEach { 109 | add(it.formatAsCsvString() + "\n") 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/data/data/Shop.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.data.data 2 | 3 | import androidx.room.Entity 4 | import androidx.room.Ignore 5 | import androidx.room.Index 6 | import androidx.room.PrimaryKey 7 | import com.kssidll.arru.domain.data.FuzzySearchSource 8 | import com.kssidll.arru.domain.data.NameSource 9 | import com.kssidll.arru.helper.generateRandomStringValue 10 | import me.xdrop.fuzzywuzzy.FuzzySearch 11 | 12 | @Entity( 13 | indices = [ 14 | Index( 15 | value = ["name"], 16 | unique = true 17 | ) 18 | ] 19 | ) 20 | data class Shop( 21 | @PrimaryKey(autoGenerate = true) val id: Long, 22 | val name: String, 23 | ): FuzzySearchSource, NameSource { 24 | /** 25 | * Converts the [Shop] data to a string with csv format 26 | * 27 | * Doesn't include the csv headers 28 | * @return [Shop] data as [String] with csv format 29 | */ 30 | @Ignore 31 | fun formatAsCsvString(): String { 32 | return "${id};${name}" 33 | } 34 | 35 | companion object { 36 | /** 37 | * Returns the [String] representing the [Shop] csv format headers 38 | * @return [String] representing the [Shop] csv format headers 39 | */ 40 | @Ignore 41 | fun csvHeaders(): String { 42 | return "id;name" 43 | } 44 | 45 | @Ignore 46 | fun generate(shopId: Long = 0): Shop { 47 | return Shop( 48 | id = shopId, 49 | name = generateRandomStringValue(), 50 | ) 51 | } 52 | 53 | @Ignore 54 | fun generateList(amount: Int = 10): List { 55 | return List(amount) { 56 | generate(it.toLong()) 57 | } 58 | } 59 | } 60 | 61 | @Ignore 62 | constructor( 63 | name: String 64 | ): this( 65 | 0, 66 | name.trim() 67 | ) 68 | 69 | @Ignore 70 | override fun fuzzyScore(query: String): Int { 71 | return FuzzySearch.extractOne( 72 | query, 73 | listOf(name) 74 | ).score 75 | } 76 | 77 | /** 78 | * @return true if name is valid, false otherwise 79 | */ 80 | @Ignore 81 | fun validName(): Boolean { 82 | return name.isNotBlank() 83 | } 84 | 85 | @Ignore 86 | override fun name(): String { 87 | return name 88 | } 89 | } 90 | 91 | /** 92 | * Converts a list of [Shop] data to a list of strings with csv format 93 | * @param includeHeaders whether to include the csv headers 94 | * @return [Shop] data as list of string with csv format 95 | */ 96 | fun List.asCsvList(includeHeaders: Boolean = false): List = buildList { 97 | // Add headers 98 | if (includeHeaders) { 99 | add(Shop.csvHeaders() + "\n") 100 | } 101 | 102 | // Add rows 103 | this@asCsvList.forEach { 104 | add(it.formatAsCsvString() + "\n") 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/data/paging/FullItemPagingSource.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.data.paging 2 | 3 | import androidx.paging.PagingSource 4 | import androidx.paging.PagingState 5 | import com.kssidll.arru.data.data.FullItem 6 | 7 | class FullItemPagingSource( 8 | private val query: suspend (start: Int, loadSize: Int) -> List, 9 | private val itemsBefore: suspend (id: Long) -> Int, 10 | private val itemsAfter: suspend (id: Long) -> Int, 11 | ): PagingSource() { 12 | override suspend fun load(params: LoadParams): LoadResult { 13 | val pageIndex = params.key ?: 0 14 | 15 | val page = query( 16 | pageIndex, 17 | params.loadSize 18 | ) 19 | 20 | val itemsBefore = 21 | if (pageIndex == 0) 0 22 | else page.firstOrNull()?.id?.let { itemsAfter(it) } ?: 0 // reversed since newest first 23 | 24 | val itemsAfter = 25 | page.lastOrNull()?.id?.let { itemsBefore(it) } ?: 0 // reversed since newest first 26 | 27 | val prevKey = if (pageIndex == 0) null else pageIndex - params.loadSize 28 | val nextKey = if (itemsAfter == 0) null else pageIndex + params.loadSize 29 | 30 | return LoadResult.Page( 31 | data = page, 32 | prevKey = prevKey, 33 | nextKey = nextKey, 34 | itemsBefore = itemsBefore, 35 | itemsAfter = itemsAfter 36 | ) 37 | } 38 | 39 | override fun getRefreshKey(state: PagingState): Int? { 40 | return state.anchorPosition?.let { 41 | state.closestPageToPosition(it)?.prevKey 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/data/repository/ImportRepository.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.data.repository 2 | 3 | import com.kssidll.arru.data.dao.ImportDao 4 | import com.kssidll.arru.data.data.Item 5 | import com.kssidll.arru.data.data.Product 6 | import com.kssidll.arru.data.data.ProductCategory 7 | import com.kssidll.arru.data.data.ProductProducer 8 | import com.kssidll.arru.data.data.ProductVariant 9 | import com.kssidll.arru.data.data.Shop 10 | import com.kssidll.arru.data.data.TransactionBasket 11 | 12 | class ImportRepository(private val dao: ImportDao): ImportRepositorySource { 13 | override suspend fun insertAll( 14 | shops: List, 15 | producers: List, 16 | categories: List, 17 | transactions: List, 18 | products: List, 19 | variants: List, 20 | items: List 21 | ) { 22 | dao.insertAll( 23 | shops = shops, 24 | producers = producers, 25 | categories = categories, 26 | transactions = transactions, 27 | products = products, 28 | variants = variants, 29 | items = items 30 | ) 31 | } 32 | 33 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/data/repository/ImportRepositorySource.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.data.repository 2 | 3 | import com.kssidll.arru.data.data.Item 4 | import com.kssidll.arru.data.data.Product 5 | import com.kssidll.arru.data.data.ProductCategory 6 | import com.kssidll.arru.data.data.ProductProducer 7 | import com.kssidll.arru.data.data.ProductVariant 8 | import com.kssidll.arru.data.data.Shop 9 | import com.kssidll.arru.data.data.TransactionBasket 10 | 11 | interface ImportRepositorySource { 12 | suspend fun insertAll( 13 | shops: List, 14 | producers: List, 15 | categories: List, 16 | transactions: List, 17 | products: List, 18 | variants: List, 19 | items: List 20 | ) 21 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/di/module/AppModule.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.di.module 2 | 3 | import dagger.Module 4 | import dagger.hilt.InstallIn 5 | import dagger.hilt.components.SingletonComponent 6 | 7 | @Module 8 | @InstallIn(SingletonComponent::class) 9 | class AppModule -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/di/module/ChangeDatabaseLocationUseCaseModule.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.di.module 2 | 3 | import android.content.Context 4 | import com.kssidll.arru.domain.usecase.ChangeDatabaseLocationUseCase 5 | import dagger.Module 6 | import dagger.Provides 7 | import dagger.hilt.InstallIn 8 | import dagger.hilt.android.qualifiers.ApplicationContext 9 | import dagger.hilt.components.SingletonComponent 10 | import javax.inject.Singleton 11 | 12 | @Module 13 | @InstallIn(SingletonComponent::class) 14 | class ChangeDatabaseLocationUseCaseModule { 15 | @Provides 16 | @Singleton 17 | fun provideChangeDatabaseLocationUseCase(@ApplicationContext context: Context): ChangeDatabaseLocationUseCase { 18 | return ChangeDatabaseLocationUseCase(context) 19 | } 20 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/di/module/DataStoreModule.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.di.module 2 | 3 | import android.content.Context 4 | import androidx.datastore.core.DataStore 5 | import androidx.datastore.preferences.core.Preferences 6 | import androidx.datastore.preferences.preferencesDataStore 7 | import com.kssidll.arru.data.preference.AppPreferences 8 | import dagger.Module 9 | import dagger.Provides 10 | import dagger.hilt.InstallIn 11 | import dagger.hilt.android.qualifiers.ApplicationContext 12 | import dagger.hilt.components.SingletonComponent 13 | import kotlinx.coroutines.flow.first 14 | import kotlinx.coroutines.runBlocking 15 | import javax.inject.Singleton 16 | 17 | val Context.dataStore: DataStore by preferencesDataStore(name = AppPreferences.DATASTORENAME) 18 | 19 | fun getPreferencesDataStore(context: Context): DataStore { 20 | return context.dataStore 21 | } 22 | 23 | suspend fun getPreferences(context: Context): Preferences { 24 | return getPreferencesDataStore(context).data.first() 25 | } 26 | 27 | suspend fun Preferences.from(context: Context): Preferences { 28 | return getPreferences(context) 29 | } 30 | 31 | suspend fun Context.preferences(): Preferences { 32 | return getPreferences(this) 33 | } 34 | 35 | @Module 36 | @InstallIn(SingletonComponent::class) 37 | class DataStoreModule { 38 | @Provides 39 | @Singleton 40 | fun provideDataStore(@ApplicationContext context: Context): DataStore { 41 | return getPreferencesDataStore(context) 42 | } 43 | 44 | @Provides 45 | fun providePreferences(@ApplicationContext context: Context): Preferences { 46 | return runBlocking { 47 | return@runBlocking getPreferences(context) 48 | } 49 | } 50 | } 51 | 52 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/di/module/ExportDataUseCaseModule.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.di.module 2 | 3 | import android.content.Context 4 | import com.kssidll.arru.data.repository.CategoryRepositorySource 5 | import com.kssidll.arru.data.repository.ItemRepositorySource 6 | import com.kssidll.arru.data.repository.ProducerRepositorySource 7 | import com.kssidll.arru.data.repository.ProductRepositorySource 8 | import com.kssidll.arru.data.repository.ShopRepositorySource 9 | import com.kssidll.arru.data.repository.TransactionBasketRepositorySource 10 | import com.kssidll.arru.data.repository.VariantRepositorySource 11 | import com.kssidll.arru.domain.usecase.ExportDataUIBlockingUseCase 12 | import com.kssidll.arru.domain.usecase.ExportDataWithServiceUseCase 13 | import dagger.Module 14 | import dagger.Provides 15 | import dagger.hilt.InstallIn 16 | import dagger.hilt.android.qualifiers.ApplicationContext 17 | import dagger.hilt.components.SingletonComponent 18 | import javax.inject.Singleton 19 | 20 | @Module 21 | @InstallIn(SingletonComponent::class) 22 | class ExportDataUseCaseModule { 23 | @Provides 24 | @Singleton 25 | fun provideExportDataWithServiceUseCase(@ApplicationContext context: Context): ExportDataWithServiceUseCase { 26 | return ExportDataWithServiceUseCase(context) 27 | } 28 | 29 | @Provides 30 | @Singleton 31 | fun provideExportDataUIBlockingUseCase( 32 | @ApplicationContext context: Context, 33 | categoryRepository: CategoryRepositorySource, 34 | itemRepository: ItemRepositorySource, 35 | producerRepository: ProducerRepositorySource, 36 | productRepository: ProductRepositorySource, 37 | shopRepository: ShopRepositorySource, 38 | transactionRepository: TransactionBasketRepositorySource, 39 | variantRepository: VariantRepositorySource, 40 | ): ExportDataUIBlockingUseCase { 41 | return ExportDataUIBlockingUseCase( 42 | context, 43 | categoryRepository, 44 | itemRepository, 45 | producerRepository, 46 | productRepository, 47 | shopRepository, 48 | transactionRepository, 49 | variantRepository 50 | ) 51 | } 52 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/di/module/ImportDataUseCaseModule.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.di.module 2 | 3 | import android.content.Context 4 | import com.kssidll.arru.data.repository.ImportRepositorySource 5 | import com.kssidll.arru.domain.usecase.ImportDataUIBlockingUseCase 6 | import dagger.Module 7 | import dagger.Provides 8 | import dagger.hilt.InstallIn 9 | import dagger.hilt.android.qualifiers.ApplicationContext 10 | import dagger.hilt.components.SingletonComponent 11 | import javax.inject.Singleton 12 | 13 | @Module 14 | @InstallIn(SingletonComponent::class) 15 | class ImportDataUseCaseModule { 16 | @Provides 17 | @Singleton 18 | fun provideImportDataUIBlockingUseCase( 19 | @ApplicationContext context: Context, 20 | importRepository: ImportRepositorySource, 21 | ): ImportDataUIBlockingUseCase { 22 | return ImportDataUIBlockingUseCase( 23 | context, 24 | importRepository 25 | ) 26 | } 27 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/domain/AppLocale.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.domain 2 | 3 | // TODO Remove when reading application supported locales is possible on API < 34 4 | // for same reason [Locale] doesn't work as intended 5 | /** 6 | * Application supported locales 7 | * @param code ISO 639-2 language code of the locale 8 | * @param tag Language tag of the locale 9 | */ 10 | enum class AppLocale( 11 | val code: String, 12 | val tag: String 13 | ) { 14 | PL( 15 | "pol", 16 | "pl-PL" 17 | ), 18 | EN_US( 19 | "eng", 20 | "en-US" 21 | ), 22 | TR( 23 | "tur", 24 | "tr-TR" 25 | ) 26 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/domain/TimePeriodFlowHandler.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.domain 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.MutableState 5 | import androidx.compose.runtime.ReadOnlyComposable 6 | import androidx.compose.runtime.getValue 7 | import androidx.compose.runtime.mutableStateOf 8 | import androidx.compose.ui.res.stringResource 9 | import com.kssidll.arru.R 10 | import kotlinx.coroutines.CoroutineScope 11 | import kotlinx.coroutines.Job 12 | import kotlinx.coroutines.flow.Flow 13 | import kotlinx.coroutines.flow.cancellable 14 | import kotlinx.coroutines.flow.distinctUntilChanged 15 | import kotlinx.coroutines.flow.flowOf 16 | import kotlinx.coroutines.launch 17 | 18 | /** 19 | * Defines available time periods and handles flow managment when changing time periods 20 | */ 21 | class TimePeriodFlowHandler( 22 | private val scope: CoroutineScope, 23 | private val dayFlow: () -> Flow, 24 | private val weekFlow: () -> Flow, 25 | private val monthFlow: () -> Flow, 26 | private val yearFlow: () -> Flow, 27 | startPeriod: Periods = Periods.Month, 28 | ) { 29 | private var mCurrentPeriod: MutableState = mutableStateOf(startPeriod) 30 | val currentPeriod get() = mCurrentPeriod.value 31 | 32 | private var mSpentByTimeQuery: Job? = null 33 | private var mSpentByTimeData: MutableState> = mutableStateOf(flowOf()) 34 | val spentByTimeData by mSpentByTimeData 35 | 36 | init { 37 | handlePeriodSwitch() 38 | } 39 | 40 | fun switchPeriod(newPeriod: Periods) { 41 | mCurrentPeriod.value = newPeriod 42 | handlePeriodSwitch() 43 | } 44 | 45 | private fun handlePeriodSwitch() { 46 | mSpentByTimeQuery?.cancel() 47 | 48 | mSpentByTimeQuery = scope.launch { 49 | when (currentPeriod) { 50 | Periods.Day -> mSpentByTimeData.value = dayFlow().distinctUntilChanged() 51 | .cancellable() 52 | 53 | Periods.Week -> mSpentByTimeData.value = weekFlow().distinctUntilChanged() 54 | .cancellable() 55 | 56 | Periods.Month -> mSpentByTimeData.value = monthFlow().distinctUntilChanged() 57 | .cancellable() 58 | 59 | Periods.Year -> mSpentByTimeData.value = yearFlow().distinctUntilChanged() 60 | .cancellable() 61 | } 62 | } 63 | } 64 | 65 | /** 66 | * Ordinal signifies which order they appear in on the UI 67 | */ 68 | enum class Periods { 69 | Day, 70 | Week, 71 | Month, 72 | Year, 73 | } 74 | } 75 | 76 | @Composable 77 | @ReadOnlyComposable 78 | fun TimePeriodFlowHandler.Periods.getTranslation(): String { 79 | return when (this) { 80 | TimePeriodFlowHandler.Periods.Day -> stringResource(R.string.day) 81 | TimePeriodFlowHandler.Periods.Week -> stringResource(R.string.week) 82 | TimePeriodFlowHandler.Periods.Month -> stringResource(R.string.month) 83 | TimePeriodFlowHandler.Periods.Year -> stringResource(R.string.year) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/domain/data/ChartSource.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.domain.data 2 | 3 | import com.patrykandpatrick.vico.core.entry.ChartEntry 4 | import java.util.Locale 5 | 6 | interface ChartSource: FloatSource, SortSource { 7 | fun chartEntry(x: Int): ChartEntry 8 | fun startAxisLabel(locale: Locale): String? 9 | fun topAxisLabel(locale: Locale): String? 10 | fun bottomAxisLabel(locale: Locale): String? 11 | fun endAxisLabel(locale: Locale): String? 12 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/domain/data/Data.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.domain.data 2 | 3 | import androidx.paging.LoadState 4 | import androidx.paging.compose.LazyPagingItems 5 | 6 | /** 7 | * A generic abstraction for repository data with loaded and loading states 8 | */ 9 | sealed class Data { 10 | /** 11 | * Signifies loaded state with some contained data value 12 | */ 13 | data class Loaded(val data: T): Data() 14 | 15 | /** 16 | * Signifies loading state without any contained data 17 | */ 18 | class Loading(): Data() 19 | } 20 | 21 | /** 22 | * Checks whether the [Data] reported loaded state but is empty 23 | * @return true if [Data] reported loaded state but has no items, false otherwise 24 | */ 25 | fun Data.loadedEmpty(): Boolean where T: List { 26 | return this is Data.Loaded && data.isEmpty() 27 | } 28 | 29 | /** 30 | * Checks whether the [Data] reported loaded state and is not empty 31 | * @return true if [Data] reported loaded state and has items, false otherwise 32 | */ 33 | fun Data.loadedData(): Boolean where T: List { 34 | return this is Data.Loaded && data.isNotEmpty() 35 | } 36 | 37 | /** 38 | * Checks whether the [LazyPagingItems] reported loaded state but is empty 39 | * @return true if [LazyPagingItems] reported loaded state but has no items, false otherwise 40 | */ 41 | fun LazyPagingItems.loadedEmpty(): Boolean where T: Any { 42 | return loadState.refresh is LoadState.NotLoading && itemCount == 0 43 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/domain/data/Field.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.domain.data 2 | 3 | import androidx.compose.material3.Text 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.res.stringResource 6 | import com.kssidll.arru.R 7 | import com.kssidll.arru.ui.theme.Typography 8 | 9 | /** 10 | * Generic abstraction for data and its status 11 | */ 12 | sealed class Field( 13 | val data: T? = null, 14 | val error: FieldError? = null, 15 | ) { 16 | /** 17 | * Field status signifying that it's loaded correctly and can display and use [data] 18 | */ 19 | class Loaded(data: T? = null): Field(data) 20 | 21 | /** 22 | * Field status signifying a loading process taking place, the field shouldn't be user changeable in this status 23 | */ 24 | class Loading(data: T? = null): Field(data) 25 | 26 | /** 27 | * Field status signifying an error 28 | * @param error [FieldError] error to set the field to 29 | */ 30 | class Error( 31 | error: FieldError? = null, 32 | data: T? = null, 33 | ): Field( 34 | data, 35 | error, 36 | ) 37 | 38 | /** 39 | * @return Whether the field should be user changeable 40 | */ 41 | fun isEnabled(): Boolean { 42 | return this !is Loading 43 | } 44 | 45 | /** 46 | * @return Whether the field is in an error state 47 | */ 48 | fun isError(): Boolean { 49 | return this is Error 50 | } 51 | 52 | /** 53 | * @return Negation of [isError] 54 | */ 55 | fun isNotError(): Boolean { 56 | return isError().not() 57 | } 58 | 59 | /** 60 | * Tries to change this field status to [Loaded] 61 | * @return This field as [Loaded] status 62 | */ 63 | fun toLoaded(): Field { 64 | return Loaded(data) 65 | } 66 | 67 | /** 68 | * Tries to change this field status to [Loaded] 69 | * @return This field as [Loaded] status or as [FieldError.NoValueError] [Error] if the data is null 70 | */ 71 | fun toLoadedOrError(): Field { 72 | return this.data?.let { Loaded(it) } ?: Error(FieldError.NoValueError) 73 | } 74 | 75 | /** 76 | * Changes this field status to [Loading] 77 | * @return This field as [Loading] 78 | */ 79 | fun toLoading(): Loading { 80 | return Loading(this.data) 81 | } 82 | 83 | /** 84 | * Changes this field status to [Error] 85 | * @param error Error to set the field to 86 | * @return This field as [Error] 87 | */ 88 | fun toError(error: FieldError? = null): Error { 89 | return Error( 90 | error, 91 | this.data 92 | ) 93 | } 94 | } 95 | 96 | /** 97 | * Possible [Field] errors 98 | */ 99 | sealed class FieldError { 100 | @Composable 101 | fun ErrorText() { 102 | Text( 103 | text = errorString(), 104 | style = Typography.bodySmall, 105 | ) 106 | } 107 | 108 | @Composable 109 | abstract fun errorString(): String 110 | 111 | /** 112 | * Error signifying lack of value in the field 113 | */ 114 | data object NoValueError: FieldError() { 115 | @Composable 116 | override fun errorString(): String { 117 | return stringResource(id = R.string.no_value_error_text) 118 | } 119 | } 120 | 121 | /** 122 | * Error signifying correct, but duplicate value that can't be used 123 | */ 124 | data object DuplicateValueError: FieldError() { 125 | @Composable 126 | override fun errorString(): String { 127 | return stringResource(id = R.string.duplicate_value_error_text) 128 | } 129 | } 130 | 131 | /** 132 | * Error signifying incorrect value 133 | */ 134 | data object InvalidValueError: FieldError() { 135 | @Composable 136 | override fun errorString(): String { 137 | return stringResource(id = R.string.invalid_value_error_text) 138 | } 139 | } 140 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/domain/data/FloatSource.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.domain.data 2 | 3 | import com.patrykandpatrick.vico.core.entry.ChartEntry 4 | import com.patrykandpatrick.vico.core.entry.FloatEntry 5 | 6 | interface FloatSource { 7 | fun value(): Float 8 | } 9 | 10 | fun List.avg(): Float where E: FloatSource { 11 | if (isEmpty()) return 0f 12 | 13 | var sum = 0f 14 | forEach { 15 | sum += it.value() 16 | } 17 | return sum / size 18 | } 19 | 20 | /** 21 | * assumes the list being ordered by time and creates a list representing the moving average in that time 22 | * @return list representing the moving average 23 | */ 24 | fun List.movingAverage(): List where E: FloatSource { 25 | if (isEmpty()) return emptyList() 26 | 27 | val results = mutableListOf() 28 | var sum = 0f 29 | forEach { item -> 30 | sum += item.value() 31 | results.add(sum / (results.size + 1)) 32 | } 33 | 34 | return results 35 | 36 | } 37 | 38 | /** 39 | * assumes the list being ordered by time and creates a list representing the moving average in that time 40 | * @return list representing the moving average 41 | */ 42 | fun List.movingAverageChartData(): List where E: FloatSource { 43 | if (isEmpty()) return emptyList() 44 | 45 | return movingAverage().mapIndexed { index, median -> 46 | FloatEntry( 47 | index.toFloat(), 48 | median 49 | ) 50 | } 51 | } 52 | 53 | /** 54 | * assumes the list being ordered by time and creates a list representing the moving total in that time 55 | * @return list representing the moving total 56 | */ 57 | fun List.movingTotal(): List where E: FloatSource { 58 | if (isEmpty()) return emptyList() 59 | 60 | val results = mutableListOf() 61 | var sum = 0f 62 | forEach { item -> 63 | sum += item.value() 64 | results.add(sum) 65 | } 66 | 67 | return results 68 | 69 | } 70 | 71 | /** 72 | * assumes the list being ordered by time and creates a list representing the moving total in that time 73 | * @return list representing the moving total 74 | */ 75 | fun List.movingTotalChartData(): List where E: FloatSource { 76 | if (isEmpty()) return emptyList() 77 | 78 | return movingTotal().mapIndexed { index, median -> 79 | FloatEntry( 80 | index.toFloat(), 81 | median 82 | ) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/domain/data/FuzzySearchSource.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.domain.data 2 | 3 | interface FuzzySearchSource { 4 | fun fuzzyScore(query: String): Int 5 | } 6 | 7 | fun List.searchSort(query: String): List where E: FuzzySearchSource { 8 | if (query.isBlank()) return this 9 | 10 | return searchSort { it.fuzzyScore(query) } 11 | } 12 | 13 | fun List.searchSort( 14 | calculateScore: (E) -> Int 15 | ): List { 16 | if (this.isEmpty()) return this 17 | 18 | return this.map { 19 | it to calculateScore(it) 20 | } 21 | .sortedByDescending { (_, score) -> score } 22 | .map { (element, _) -> element } 23 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/domain/data/IdentitySource.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.domain.data 2 | 3 | interface IdentitySource { 4 | fun identificator(): Long 5 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/domain/data/NameSource.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.domain.data 2 | 3 | interface NameSource { 4 | fun name(): String 5 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/domain/data/RankSource.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.domain.data 2 | 3 | import java.util.Locale 4 | 5 | interface RankSource: SortSource, FloatSource, IdentitySource { 6 | fun displayName(): String 7 | fun displayValue(locale: Locale): String 8 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/domain/data/SortSource.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.domain.data 2 | 3 | import com.patrykandpatrick.vico.core.entry.ChartEntry 4 | import com.patrykandpatrick.vico.core.entry.FloatEntry 5 | 6 | interface SortSource { 7 | fun sortValue(): Long 8 | } 9 | 10 | fun List.median(): Float where E: SortSource, E: FloatSource { 11 | if (isEmpty()) return 0f 12 | 13 | val sorted = sortedBy { it.sortValue() } 14 | val middle = sorted.size.div(2) - 1 15 | 16 | if (sorted.size % 2 == 1) return sorted[middle + 1].value() 17 | 18 | return (sorted[middle].value() + sorted[middle + 1].value()).div(2) 19 | } 20 | 21 | /** 22 | * assumes the list being ordered by time and creates a list representing the moving median in that time 23 | * @return list representing the moving median 24 | */ 25 | fun List.movingMedian(): List where E: SortSource, E: FloatSource { 26 | if (isEmpty()) return emptyList() 27 | 28 | val results = mutableListOf() 29 | val calculationList = mutableListOf() 30 | forEach { item -> 31 | calculationList.add(item) 32 | 33 | val sorted = calculationList.sortedBy { it.sortValue() } 34 | val middle = sorted.size.div(2) - 1 35 | 36 | if (sorted.size % 2 == 1) results.add(sorted[middle + 1].value()) 37 | else results.add((sorted[middle].value() + sorted[middle + 1].value()).div(2)) 38 | } 39 | 40 | return results 41 | 42 | } 43 | 44 | /** 45 | * assumes the list being ordered by time and creates a list representing the moving median in that time 46 | * @return list representing the moving median 47 | */ 48 | fun List.movingMedianChartData(): List where E: SortSource, E: FloatSource { 49 | if (isEmpty()) return emptyList() 50 | 51 | return movingMedian().mapIndexed { index, median -> 52 | FloatEntry( 53 | index.toFloat(), 54 | median 55 | ) 56 | } 57 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/domain/usecase/ChangeDatabaseLocationUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.domain.usecase 2 | 3 | import android.content.Context 4 | import androidx.annotation.RequiresApi 5 | import com.kssidll.arru.data.preference.AppPreferences 6 | import com.kssidll.arru.data.preference.setDatabaseLocation 7 | import dagger.hilt.android.qualifiers.ApplicationContext 8 | import kotlinx.coroutines.CoroutineDispatcher 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.withContext 11 | 12 | enum class DatabaseMoveResult { 13 | SUCCESS, 14 | FAILED, 15 | REQUEST_CONFIRMATION 16 | } 17 | 18 | class ChangeDatabaseLocationUseCase( 19 | @ApplicationContext val appContext: Context, 20 | private val dispatcher: CoroutineDispatcher = Dispatchers.Default, 21 | ) { 22 | @RequiresApi(30) 23 | suspend operator fun invoke( 24 | newDatabaseLocation: AppPreferences.Database.Location.Values, 25 | force: Boolean = false 26 | ) = withContext(dispatcher) { 27 | if (newDatabaseLocation == AppPreferences.Database.Location.Values.DOWNLOADS && !force) { 28 | return@withContext DatabaseMoveResult.REQUEST_CONFIRMATION 29 | } 30 | 31 | val setLocation = AppPreferences.setDatabaseLocation(appContext, newDatabaseLocation) 32 | 33 | if (setLocation != newDatabaseLocation) { 34 | return@withContext DatabaseMoveResult.FAILED 35 | } 36 | 37 | return@withContext DatabaseMoveResult.SUCCESS 38 | } 39 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/domain/usecase/ExportDataUIBlockingUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.domain.usecase 2 | 3 | import android.content.Context 4 | import android.net.Uri 5 | import com.kssidll.arru.data.database.exportDataAsCompactCsv 6 | import com.kssidll.arru.data.database.exportDataAsJson 7 | import com.kssidll.arru.data.database.exportDataAsRawCsv 8 | import com.kssidll.arru.data.preference.AppPreferences 9 | import com.kssidll.arru.data.preference.getExportType 10 | import com.kssidll.arru.data.repository.CategoryRepositorySource 11 | import com.kssidll.arru.data.repository.ItemRepositorySource 12 | import com.kssidll.arru.data.repository.ProducerRepositorySource 13 | import com.kssidll.arru.data.repository.ProductRepositorySource 14 | import com.kssidll.arru.data.repository.ShopRepositorySource 15 | import com.kssidll.arru.data.repository.TransactionBasketRepositorySource 16 | import com.kssidll.arru.data.repository.VariantRepositorySource 17 | import dagger.hilt.android.qualifiers.ApplicationContext 18 | import kotlinx.coroutines.CoroutineDispatcher 19 | import kotlinx.coroutines.Dispatchers 20 | import kotlinx.coroutines.flow.first 21 | import kotlinx.coroutines.withContext 22 | 23 | class ExportDataUIBlockingUseCase( 24 | @ApplicationContext val appContext: Context, 25 | private val categoryRepository: CategoryRepositorySource, 26 | private val itemRepository: ItemRepositorySource, 27 | private val producerRepository: ProducerRepositorySource, 28 | private val productRepository: ProductRepositorySource, 29 | private val shopRepository: ShopRepositorySource, 30 | private val transactionRepository: TransactionBasketRepositorySource, 31 | private val variantRepository: VariantRepositorySource, 32 | private val dispatcher: CoroutineDispatcher = Dispatchers.IO, 33 | ) { 34 | suspend operator fun invoke( 35 | uri: Uri, 36 | onMaxProgressChange: (newMaxProgress: Int) -> Unit, 37 | onProgressChange: (newProgress: Int) -> Unit, 38 | onFinished: () -> Unit, 39 | ) = withContext(dispatcher) { 40 | when (AppPreferences.getExportType(appContext).first()) { 41 | AppPreferences.Export.Type.Values.CompactCSV -> { 42 | exportDataAsCompactCsv( 43 | appContext, 44 | uri, 45 | categoryRepository, 46 | itemRepository, 47 | producerRepository, 48 | productRepository, 49 | shopRepository, 50 | transactionRepository, 51 | variantRepository, 52 | onMaxProgressChange, 53 | onProgressChange, 54 | onFinished, 55 | ) 56 | } 57 | 58 | AppPreferences.Export.Type.Values.RawCSV -> { 59 | exportDataAsRawCsv( 60 | appContext, 61 | uri, 62 | categoryRepository, 63 | itemRepository, 64 | producerRepository, 65 | productRepository, 66 | shopRepository, 67 | transactionRepository, 68 | variantRepository, 69 | onMaxProgressChange, 70 | onProgressChange, 71 | onFinished, 72 | ) 73 | } 74 | 75 | AppPreferences.Export.Type.Values.JSON -> { 76 | exportDataAsJson( 77 | appContext, 78 | uri, 79 | categoryRepository, 80 | itemRepository, 81 | producerRepository, 82 | productRepository, 83 | shopRepository, 84 | transactionRepository, 85 | variantRepository, 86 | onMaxProgressChange, 87 | onProgressChange, 88 | onFinished, 89 | ) 90 | } 91 | } 92 | } 93 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/domain/usecase/ExportDataWithServiceUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.domain.usecase 2 | 3 | import android.content.Context 4 | import android.net.Uri 5 | import com.kssidll.arru.data.preference.AppPreferences 6 | import com.kssidll.arru.data.preference.getExportType 7 | import com.kssidll.arru.service.DataExportService 8 | import dagger.hilt.android.qualifiers.ApplicationContext 9 | import kotlinx.coroutines.CoroutineDispatcher 10 | import kotlinx.coroutines.Dispatchers 11 | import kotlinx.coroutines.flow.first 12 | import kotlinx.coroutines.withContext 13 | 14 | class ExportDataWithServiceUseCase( 15 | @ApplicationContext val appContext: Context, 16 | private val dispatcher: CoroutineDispatcher = Dispatchers.Default, 17 | ) { 18 | suspend operator fun invoke(uri: Uri) = withContext(dispatcher) { 19 | when (AppPreferences.getExportType(appContext).first()) { 20 | AppPreferences.Export.Type.Values.CompactCSV -> { 21 | DataExportService.startExportCsvCompact( 22 | appContext, 23 | uri 24 | ) 25 | } 26 | 27 | AppPreferences.Export.Type.Values.RawCSV -> { 28 | DataExportService.startExportCsvRaw( 29 | appContext, 30 | uri 31 | ) 32 | } 33 | 34 | AppPreferences.Export.Type.Values.JSON -> { 35 | DataExportService.startExportJson( 36 | appContext, 37 | uri 38 | ) 39 | } 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/domain/usecase/ImportDataUIBlockingUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.domain.usecase 2 | 3 | import android.content.Context 4 | import android.net.Uri 5 | import com.kssidll.arru.data.database.ImportError 6 | import com.kssidll.arru.data.database.importDataFromUris 7 | import com.kssidll.arru.data.repository.ImportRepositorySource 8 | import dagger.hilt.android.qualifiers.ApplicationContext 9 | import kotlinx.coroutines.CoroutineDispatcher 10 | import kotlinx.coroutines.Dispatchers 11 | import kotlinx.coroutines.withContext 12 | 13 | class ImportDataUIBlockingUseCase( 14 | @ApplicationContext val appContext: Context, 15 | private val importRepository: ImportRepositorySource, 16 | private val dispatcher: CoroutineDispatcher = Dispatchers.IO, 17 | ) { 18 | suspend operator fun invoke( 19 | uri: Uri, 20 | onMaxProgressChange: (newMaxProgress: Int) -> Unit, 21 | onProgressChange: (newProgress: Int) -> Unit, 22 | onFinished: () -> Unit, 23 | onError: (error: ImportError) -> Unit, 24 | ) = withContext(dispatcher) { 25 | importDataFromUris( 26 | context = appContext, 27 | uri = uri, 28 | importRepository = importRepository, 29 | onMaxProgressChange = onMaxProgressChange, 30 | onProgressChange = onProgressChange, 31 | onFinished = onFinished, 32 | onError = onError 33 | ) 34 | } 35 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/domain/utils/Currency.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("unused") 2 | 3 | package com.kssidll.arru.domain.utils 4 | 5 | import java.text.NumberFormat 6 | import java.util.Locale 7 | 8 | fun Float.formatToCurrency( 9 | locale: Locale, 10 | dropDecimal: Boolean = false, 11 | ): String { 12 | val numberFormat = NumberFormat.getCurrencyInstance(locale) 13 | 14 | if (dropDecimal) numberFormat.maximumFractionDigits = 0 15 | 16 | return numberFormat.format(this) 17 | } 18 | 19 | fun Long.formatToCurrency( 20 | locale: Locale, 21 | ): String { 22 | return toFloat().formatToCurrency( 23 | locale, 24 | dropDecimal = true 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/helper/DataGenerators.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.helper 2 | 3 | import android.annotation.SuppressLint 4 | import java.sql.Date 5 | import java.text.SimpleDateFormat 6 | import java.util.Locale 7 | import kotlin.random.Random 8 | 9 | private val defaultTimeFrom: Long = Date.valueOf("2020-01-01").time 10 | private val defaultTimeUntil: Long = Date.valueOf("2025-12-31").time 11 | private const val defaultDateStringFormatting: String = "yyyy-MM-dd" 12 | 13 | @SuppressLint("ConstantLocale") 14 | private val defaultLocale: Locale = Locale.getDefault() 15 | private const val defaultStringLength: Int = 10 16 | private const val defaultStringLengthFrom: Int = 4 17 | private const val defaultStringLengthUntil: Int = 12 18 | private const val defaultStringAllowedCharacters: String = "pyfgcrlaoeuidhtnsqjkxbmwvz" 19 | private const val defaultLongValueFrom: Long = 10000 20 | private const val defaultLongValueUntil: Long = 100000 21 | private const val defaultFloatDivisionFactor: Long = 100 22 | 23 | fun generateRandomTime( 24 | timeFrom: Long = defaultTimeFrom, 25 | timeUntil: Long = defaultTimeUntil, 26 | ): Long { 27 | return Random.nextLong( 28 | from = (timeFrom / 86400000), 29 | until = (timeUntil / 86400000), 30 | ) * 86400000 31 | } 32 | 33 | fun generateRandomDate( 34 | timeFrom: Long = defaultTimeFrom, 35 | timeUntil: Long = defaultTimeUntil, 36 | ): Date { 37 | return Date( 38 | generateRandomTime( 39 | timeFrom, 40 | timeUntil 41 | ) 42 | ) 43 | } 44 | 45 | fun generateRandomDateString( 46 | timeFrom: Long = defaultTimeFrom, 47 | timeUntil: Long = defaultTimeUntil, 48 | dateFormatting: String = defaultDateStringFormatting, 49 | dateLocale: Locale = defaultLocale, 50 | ): String { 51 | return SimpleDateFormat( 52 | dateFormatting, 53 | dateLocale 54 | ).format( 55 | generateRandomDate( 56 | timeFrom, 57 | timeUntil 58 | ) 59 | ) 60 | } 61 | 62 | fun generateRandomStringValue( 63 | stringLength: Int = defaultStringLength, 64 | allowedCharacters: String = defaultStringAllowedCharacters, 65 | ): String { 66 | return List(stringLength) { 67 | allowedCharacters[Random.nextInt(allowedCharacters.length)] 68 | }.toCharArray() 69 | .concatToString() 70 | } 71 | 72 | fun generateRandomStringValue( 73 | stringLengthFrom: Int = defaultStringLengthFrom, 74 | stringLengthUntil: Int = defaultStringLengthUntil, 75 | allowedCharacters: String = defaultStringAllowedCharacters, 76 | ): String { 77 | return generateRandomStringValue( 78 | stringLength = Random.nextInt( 79 | stringLengthFrom, 80 | stringLengthUntil 81 | ), 82 | allowedCharacters = allowedCharacters, 83 | ) 84 | } 85 | 86 | fun generateRandomLongValue( 87 | valueFrom: Long = defaultLongValueFrom, 88 | valueUntil: Long = defaultLongValueUntil, 89 | ): Long { 90 | return Random.nextLong( 91 | from = valueFrom, 92 | until = valueUntil, 93 | ) 94 | } 95 | 96 | fun generateRandomFloatValue( 97 | valueFrom: Long = defaultLongValueFrom, 98 | valueUntil: Long = defaultLongValueUntil, 99 | divisionFactor: Long = defaultFloatDivisionFactor, 100 | ): Float { 101 | return Random.nextLong( 102 | from = valueFrom, 103 | until = valueUntil, 104 | ) 105 | .toFloat() 106 | .div(divisionFactor) 107 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/helper/Locale.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.helper 2 | 3 | import android.content.Context 4 | import android.content.res.Configuration 5 | 6 | /** 7 | * Helper function to get localized string when getString for some reason doesn't localize it 8 | */ 9 | fun Context.getLocalizedString(resourseId: Int): String { 10 | val config = resources.configuration 11 | val locale = java.util.Locale.getDefault() 12 | val newConfig = Configuration(config) 13 | newConfig.setLocale(locale) 14 | return createConfigurationContext(newConfig).getString(resourseId) 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/helper/NavigationSuiteScaffold.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.helper 2 | 3 | import androidx.compose.material3.adaptive.WindowAdaptiveInfo 4 | import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType 5 | import androidx.window.core.layout.WindowWidthSizeClass 6 | 7 | object BetterNavigationSuiteScaffoldDefaults { 8 | fun calculateFromAdaptiveInfo(adaptiveInfo: WindowAdaptiveInfo): NavigationSuiteType { 9 | return with(adaptiveInfo) { 10 | if ( 11 | windowPosture.isTabletop || 12 | windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT 13 | ) { 14 | NavigationSuiteType.NavigationBar 15 | } else if ( 16 | windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED || 17 | windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.MEDIUM 18 | ) { 19 | NavigationSuiteType.NavigationRail 20 | } else { 21 | NavigationSuiteType.NavigationBar 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/helper/Permission.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.helper 2 | 3 | import android.content.Context 4 | import android.content.pm.PackageManager 5 | import androidx.core.app.ActivityCompat 6 | 7 | /** 8 | * Checks whether [permission] is granted 9 | * @param context application context 10 | * @param permission permission to check 11 | * @return whether permission is granted 12 | */ 13 | fun checkPermission( 14 | context: Context, 15 | permission: String 16 | ): Boolean { 17 | return ActivityCompat.checkSelfPermission( 18 | context, 19 | permission 20 | ) == PackageManager.PERMISSION_GRANTED 21 | } 22 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/helper/RegexHelper.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.helper 2 | 3 | sealed class RegexHelper { 4 | companion object { 5 | /** 6 | * Checks whether [value] is a representation of digits optionally divided by some other character 7 | * @param value Value to check 8 | * @param decimalPoints Optional max amount of digits after the non digit character, null means no limit 9 | * @return True if [value] is a representation of digits optionally divided by some other character, False otherwise 10 | */ 11 | fun isFloat( 12 | value: String, 13 | decimalPoints: Int? = null 14 | ): Boolean { 15 | if (decimalPoints == null) { 16 | return value.matches(Regex("\\d+?\\D?\\d*")) 17 | } 18 | 19 | return value.matches(Regex("\\d+?\\D?\\d{0,${decimalPoints}}")) 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/helper/StringHelper.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.helper 2 | 3 | class StringHelper { 4 | companion object { 5 | fun toDoubleOrNull(value: String): Double? { 6 | return if (RegexHelper.isFloat(value)) { 7 | value.replace( 8 | Regex("\\D"), 9 | "." 10 | ) 11 | .toDoubleOrNull() 12 | } else null 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/helper/Uri.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.helper 2 | 3 | import android.net.Uri 4 | 5 | fun getReadablePathFromUri(uri: Uri): String { 6 | val path = uri.path 7 | if (path != null) { 8 | // Extract the part after "/tree/" 9 | val treePath = path.substringAfter("/tree/", "") 10 | if (treePath.isNotEmpty()) { 11 | return treePath.replace(':', '/').substringAfterLast("//") 12 | } 13 | } 14 | 15 | // If all else fails, return the URI as a string 16 | return uri.toString() 17 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/service/State.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.service 2 | 3 | import android.app.ActivityManager 4 | import android.content.Context 5 | import android.content.SharedPreferences 6 | import androidx.core.content.ContextCompat 7 | 8 | /** 9 | * Helper function to remove the need to define the [Context.getSharedPreferences] mode 10 | * as the only non deprecated mode is [Context.MODE_PRIVATE] 11 | * @return [SharedPreferences] 12 | */ 13 | fun Context.getSharedPreferences(name: String): SharedPreferences { 14 | return getSharedPreferences( 15 | name, 16 | Context.MODE_PRIVATE 17 | ) 18 | } 19 | 20 | /** 21 | * Possible states that a service can be in 22 | */ 23 | enum class ServiceState { 24 | STOPPED, 25 | STARTED 26 | } 27 | 28 | /** 29 | * Transforms the name of the service into its state key used in [SharedPreferences] 30 | */ 31 | private fun serviceNameToStateKey(serviceName: String): String = "${serviceName}_STATE" 32 | 33 | /** 34 | * Sets the service state to provided [state] 35 | * @param serviceName name of the service 36 | * @param state state to set the service to 37 | */ 38 | fun Context.setServiceState( 39 | serviceName: String, 40 | state: ServiceState 41 | ) { 42 | getSharedPreferences(serviceName).edit() 43 | .let { 44 | it.putString( 45 | serviceNameToStateKey(serviceName), 46 | state.name 47 | ) 48 | it.commit() 49 | } 50 | } 51 | 52 | /** 53 | * Gets the service state of the service with provided [serviceName] 54 | * @param serviceName name of the service 55 | * @return [ServiceState] of the service 56 | */ 57 | fun Context.getServiceState(serviceName: String): ServiceState { 58 | val value = getSharedPreferences(serviceName).getString( 59 | serviceNameToStateKey(serviceName), 60 | ServiceState.STOPPED.name 61 | )!! // it's impossible for this to be null, but kotlin LSP thinks otherwise? i'm confused 62 | 63 | return ServiceState.valueOf(value) 64 | } 65 | 66 | /** 67 | * Gets the service state of the service with provided [serviceClass] 68 | * 69 | * Checks the system instead of the shared preferences so it can't be listened on 70 | * and isn't efficient, it also relies on a deprecated method that exists only for compatibility 71 | * @param serviceClass class of the service 72 | * @return [ServiceState] of the service 73 | */ 74 | // we use the deprecated method for the purpose that it's backwards compatible for 75 | @Suppress("DEPRECATION") 76 | fun Context.getServiceStateCold(serviceClass: Class<*>): ServiceState { 77 | val manager = ContextCompat.getSystemService( 78 | this, 79 | ActivityManager::class.java 80 | ) 81 | for (service in manager!!.getRunningServices(Int.MAX_VALUE)) { 82 | if (serviceClass.name == service.service.className) { 83 | return ServiceState.STARTED 84 | } 85 | } 86 | return ServiceState.STOPPED 87 | } 88 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/component/dialog/MergeConfirmDialog.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.component.dialog 2 | 3 | import androidx.compose.foundation.layout.Row 4 | import androidx.compose.foundation.layout.fillMaxWidth 5 | import androidx.compose.foundation.layout.heightIn 6 | import androidx.compose.foundation.layout.width 7 | import androidx.compose.material3.AlertDialog 8 | import androidx.compose.material3.Button 9 | import androidx.compose.material3.ButtonDefaults 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.material3.Surface 12 | import androidx.compose.material3.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.graphics.Color 16 | import androidx.compose.ui.res.stringResource 17 | import androidx.compose.ui.text.style.TextAlign 18 | import androidx.compose.ui.tooling.preview.PreviewLightDark 19 | import androidx.compose.ui.unit.dp 20 | import androidx.compose.ui.window.DialogProperties 21 | import com.kssidll.arru.R 22 | import com.kssidll.arru.ui.theme.ArrugarqTheme 23 | import com.kssidll.arru.ui.theme.Typography 24 | 25 | @Composable 26 | fun MergeConfirmDialog( 27 | message: String, 28 | onCancel: () -> Unit, 29 | onConfirm: () -> Unit, 30 | ) { 31 | AlertDialog( 32 | onDismissRequest = onCancel, 33 | confirmButton = { 34 | Row { 35 | Button( 36 | onClick = onCancel, 37 | colors = ButtonDefaults.buttonColors( 38 | containerColor = Color.Transparent, 39 | contentColor = MaterialTheme.colorScheme.tertiary, 40 | ), 41 | modifier = Modifier.weight(1F) 42 | ) { 43 | Text( 44 | text = stringResource(id = R.string.merge_action_cancel), 45 | style = Typography.bodyLarge 46 | ) 47 | } 48 | 49 | Button( 50 | onClick = onConfirm, 51 | colors = ButtonDefaults.buttonColors( 52 | containerColor = Color.Transparent, 53 | contentColor = MaterialTheme.colorScheme.tertiary, 54 | ), 55 | modifier = Modifier.weight(1F) 56 | ) { 57 | Text( 58 | text = stringResource(id = R.string.merge_action_confirm), 59 | style = Typography.bodyLarge 60 | ) 61 | } 62 | 63 | } 64 | }, 65 | title = { 66 | Text( 67 | text = stringResource(id = R.string.merge_action), 68 | textAlign = TextAlign.Center, 69 | modifier = Modifier.fillMaxWidth() 70 | ) 71 | }, 72 | text = { 73 | Text( 74 | text = message, 75 | ) 76 | }, 77 | properties = DialogProperties( 78 | dismissOnClickOutside = false, 79 | ), 80 | modifier = Modifier 81 | .width(360.dp) 82 | .heightIn(min = 200.dp) 83 | ) 84 | } 85 | 86 | @PreviewLightDark 87 | @Composable 88 | private fun MergeConfirmDialogPreview() { 89 | ArrugarqTheme { 90 | Surface { 91 | MergeConfirmDialog( 92 | message = "test", 93 | onCancel = {}, 94 | onConfirm = {}, 95 | ) 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/component/list/BaseClickableListItem.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.component.list 2 | 3 | import androidx.compose.foundation.ExperimentalFoundationApi 4 | import androidx.compose.foundation.combinedClickable 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.material3.Surface 9 | import androidx.compose.material3.Text 10 | import androidx.compose.material3.minimumInteractiveComponentSize 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.semantics.Role 15 | import androidx.compose.ui.text.style.TextAlign 16 | import androidx.compose.ui.tooling.preview.PreviewLightDark 17 | import androidx.compose.ui.unit.dp 18 | import com.kssidll.arru.ui.theme.ArrugarqTheme 19 | import com.kssidll.arru.ui.theme.Typography 20 | 21 | @OptIn(ExperimentalFoundationApi::class) 22 | @Composable 23 | fun BaseClickableListItem( 24 | text: String, 25 | onClick: (() -> Unit)? = null, 26 | onClickLabel: String? = null, 27 | onLongClick: (() -> Unit)? = null, 28 | onLongClickLabel: String? = null, 29 | ) { 30 | Box( 31 | modifier = Modifier 32 | .fillMaxWidth() 33 | .minimumInteractiveComponentSize() 34 | .combinedClickable( 35 | role = Role.Button, 36 | onClick = { 37 | onClick?.invoke() 38 | }, 39 | onClickLabel = onClickLabel, 40 | onLongClick = { 41 | onLongClick?.invoke() 42 | }, 43 | onLongClickLabel = onLongClickLabel, 44 | ) 45 | ) { 46 | Text( 47 | text = text, 48 | style = Typography.titleLarge, 49 | textAlign = TextAlign.Center, 50 | modifier = Modifier 51 | .padding( 52 | vertical = 16.dp, 53 | horizontal = 4.dp 54 | ) 55 | .align(Alignment.Center) 56 | ) 57 | } 58 | } 59 | 60 | @PreviewLightDark 61 | @Composable 62 | private fun BaseClickableListItemPreview() { 63 | ArrugarqTheme { 64 | Surface { 65 | BaseClickableListItem( 66 | text = "test" 67 | ) 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/component/other/Loading.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.component.other 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.size 7 | import androidx.compose.material3.CircularProgressIndicator 8 | import androidx.compose.material3.MaterialTheme 9 | import androidx.compose.material3.Surface 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.tooling.preview.PreviewLightDark 14 | import androidx.compose.ui.unit.dp 15 | import com.kssidll.arru.ui.theme.ArrugarqTheme 16 | 17 | @Composable 18 | fun Loading() { 19 | Row( 20 | modifier = Modifier.fillMaxSize(), 21 | verticalAlignment = Alignment.CenterVertically, 22 | horizontalArrangement = Arrangement.Center 23 | ) { 24 | CircularProgressIndicator( 25 | color = MaterialTheme.colorScheme.onBackground, 26 | modifier = Modifier.size(150.dp), 27 | strokeWidth = 12.dp 28 | ) 29 | } 30 | } 31 | 32 | @PreviewLightDark 33 | @Composable 34 | private fun LoadingPreview() { 35 | ArrugarqTheme { 36 | Surface(modifier = Modifier.fillMaxSize()) { 37 | Loading() 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/component/other/ProgressBar.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.component.other 2 | 3 | 4 | import androidx.compose.animation.core.AnimationSpec 5 | import androidx.compose.animation.core.animateFloatAsState 6 | import androidx.compose.animation.core.tween 7 | import androidx.compose.foundation.background 8 | import androidx.compose.foundation.layout.Box 9 | import androidx.compose.foundation.layout.fillMaxHeight 10 | import androidx.compose.foundation.layout.fillMaxWidth 11 | import androidx.compose.material3.MaterialTheme 12 | import androidx.compose.material3.ShapeDefaults 13 | import androidx.compose.material3.Surface 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.LaunchedEffect 16 | import androidx.compose.runtime.getValue 17 | import androidx.compose.runtime.mutableFloatStateOf 18 | import androidx.compose.runtime.remember 19 | import androidx.compose.runtime.setValue 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.draw.clip 22 | import androidx.compose.ui.graphics.Color 23 | import androidx.compose.ui.graphics.Shape 24 | import androidx.compose.ui.tooling.preview.PreviewLightDark 25 | import com.kssidll.arru.ui.theme.ArrugarqTheme 26 | 27 | /** 28 | * A progress bar component 29 | * @param progressValue Percentage, from 0.0 to 1.0, of the progress bar to fill 30 | * @param modifier Container modifier 31 | * @param color Color of the progress bar 32 | * @param shape Shape of the progress bar 33 | * @param animationSpec Animation to use to change the progress percentage value throught time. Tween will be used as default 34 | */ 35 | @Composable 36 | fun ProgressBar( 37 | progressValue: Float, 38 | modifier: Modifier = Modifier, 39 | color: Color = MaterialTheme.colorScheme.primary, 40 | shape: Shape = ShapeDefaults.Medium, 41 | animationSpec: AnimationSpec = tween(1200), 42 | ) { 43 | var targetValue by remember { mutableFloatStateOf(0F) } 44 | 45 | LaunchedEffect(progressValue) { 46 | targetValue = progressValue 47 | } 48 | 49 | val animatedValue by animateFloatAsState( 50 | targetValue = targetValue, 51 | animationSpec = animationSpec, 52 | label = "Progress bar value animation" 53 | ) 54 | 55 | Box( 56 | modifier = modifier 57 | .clip(shape) 58 | ) { 59 | Box( 60 | modifier = Modifier 61 | .fillMaxHeight() 62 | .fillMaxWidth(animatedValue) 63 | .clip(shape) 64 | .background(color) 65 | ) 66 | } 67 | } 68 | 69 | 70 | @PreviewLightDark 71 | @Composable 72 | private fun ProgressBarPreview() { 73 | ArrugarqTheme { 74 | Surface { 75 | ProgressBar( 76 | progressValue = 0.7f, 77 | ) 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/component/other/SecondaryAppBar.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.component.other 2 | 3 | import androidx.compose.foundation.layout.RowScope 4 | import androidx.compose.foundation.layout.WindowInsets 5 | import androidx.compose.foundation.layout.size 6 | import androidx.compose.material.icons.Icons 7 | import androidx.compose.material.icons.automirrored.rounded.ArrowBack 8 | import androidx.compose.material3.CenterAlignedTopAppBar 9 | import androidx.compose.material3.ExperimentalMaterial3Api 10 | import androidx.compose.material3.Icon 11 | import androidx.compose.material3.IconButton 12 | import androidx.compose.material3.MaterialTheme 13 | import androidx.compose.material3.Surface 14 | import androidx.compose.material3.Text 15 | import androidx.compose.material3.TopAppBarDefaults 16 | import androidx.compose.material3.TopAppBarScrollBehavior 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.res.stringResource 20 | import androidx.compose.ui.tooling.preview.PreviewLightDark 21 | import androidx.compose.ui.unit.dp 22 | import com.kssidll.arru.ui.theme.ArrugarqTheme 23 | 24 | @OptIn(ExperimentalMaterial3Api::class) 25 | @Composable 26 | fun SecondaryAppBar( 27 | onBack: () -> Unit, 28 | title: @Composable () -> Unit, 29 | modifier: Modifier = Modifier, 30 | actions: @Composable RowScope.() -> Unit = {}, 31 | windowInsets: WindowInsets = TopAppBarDefaults.windowInsets, 32 | scrollBehavior: TopAppBarScrollBehavior? = null, 33 | ) { 34 | CenterAlignedTopAppBar( 35 | title = title, 36 | modifier = modifier, 37 | navigationIcon = { 38 | IconButton( 39 | onClick = onBack 40 | ) { 41 | Icon( 42 | imageVector = Icons.AutoMirrored.Rounded.ArrowBack, 43 | contentDescription = stringResource(com.kssidll.arru.R.string.navigate_to_previous_screen), 44 | modifier = Modifier.size(30.dp), 45 | ) 46 | } 47 | }, 48 | actions = actions, 49 | windowInsets = windowInsets, 50 | colors = TopAppBarDefaults.centerAlignedTopAppBarColors( 51 | containerColor = MaterialTheme.colorScheme.surfaceContainer, 52 | scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer, 53 | navigationIconContentColor = MaterialTheme.colorScheme.onSurface, 54 | titleContentColor = MaterialTheme.colorScheme.onSurface, 55 | actionIconContentColor = MaterialTheme.colorScheme.onSurface, 56 | ), 57 | scrollBehavior = scrollBehavior, 58 | ) 59 | } 60 | 61 | @OptIn(ExperimentalMaterial3Api::class) 62 | @PreviewLightDark 63 | @Composable 64 | private fun SecondaryAppBarPreview() { 65 | ArrugarqTheme { 66 | Surface { 67 | SecondaryAppBar( 68 | onBack = {}, 69 | title = { 70 | Text(text = "test") 71 | }, 72 | ) 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/backups/BackupsRoute.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.backups 2 | 3 | import androidx.compose.runtime.Composable 4 | import dev.olshevski.navigation.reimagined.hilt.hiltViewModel 5 | 6 | @Composable 7 | fun BackupsRoute( 8 | navigateBack: () -> Unit, 9 | viewModel: BackupsViewModel = hiltViewModel() 10 | ) { 11 | BackupsScreen( 12 | createBackup = { 13 | viewModel.createDbBackup() 14 | }, 15 | loadBackup = { 16 | viewModel.loadDbBackup(it) 17 | }, 18 | deleteBackup = { 19 | viewModel.deleteDbBackup(it) 20 | }, 21 | toggleLockBackup = { 22 | viewModel.toggleLockDbBackup(it) 23 | }, 24 | availableBackups = viewModel.availableBackups.toList(), 25 | onBack = navigateBack, 26 | ) 27 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/backups/BackupsViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.backups 2 | 3 | import android.content.Context 4 | import androidx.compose.runtime.mutableStateListOf 5 | import androidx.compose.runtime.snapshots.SnapshotStateList 6 | import androidx.lifecycle.ViewModel 7 | import androidx.lifecycle.viewModelScope 8 | import com.kssidll.arru.data.data.DatabaseBackup 9 | import com.kssidll.arru.data.database.AppDatabase 10 | import com.kssidll.arru.data.repository.TransactionBasketRepositorySource 11 | import com.kssidll.arru.domain.data.Data 12 | import dagger.hilt.android.lifecycle.HiltViewModel 13 | import dagger.hilt.android.qualifiers.ApplicationContext 14 | import kotlinx.coroutines.Dispatchers 15 | import kotlinx.coroutines.invoke 16 | import kotlinx.coroutines.launch 17 | import javax.inject.Inject 18 | 19 | @HiltViewModel 20 | class BackupsViewModel @Inject constructor( 21 | @ApplicationContext private val context: Context, 22 | private val transactionBasketRepository: TransactionBasketRepositorySource, 23 | ): ViewModel() { 24 | val availableBackups: SnapshotStateList = mutableStateListOf() 25 | 26 | init { 27 | viewModelScope.launch { 28 | refreshAvailableBackups() 29 | } 30 | } 31 | 32 | /** 33 | * Refreshes the available backups list to represent currently available backups 34 | */ 35 | private suspend fun refreshAvailableBackups() { 36 | val newAvailableBackups = AppDatabase.availableBackups(context) 37 | 38 | availableBackups.clear() 39 | availableBackups.addAll(newAvailableBackups) 40 | } 41 | 42 | private fun lockDbBackup(dbBackup: DatabaseBackup) = viewModelScope.launch { 43 | AppDatabase.lockDbBackup(dbBackup) 44 | 45 | refreshAvailableBackups() 46 | } 47 | 48 | private fun unlockDbBackup(dbBackup: DatabaseBackup) = viewModelScope.launch { 49 | AppDatabase.unlockDbBackup(dbBackup) 50 | 51 | refreshAvailableBackups() 52 | } 53 | 54 | fun toggleLockDbBackup(dbBackup: DatabaseBackup) = viewModelScope.launch { 55 | if (dbBackup.locked) { 56 | unlockDbBackup(dbBackup) 57 | } else { 58 | lockDbBackup(dbBackup) 59 | } 60 | } 61 | 62 | /** 63 | * Creates a backup of current database 64 | */ 65 | fun createDbBackup() = viewModelScope.launch { 66 | Dispatchers.IO.invoke { 67 | // TODO add notification when you create maybe? 68 | val totalTransactions = transactionBasketRepository.count() 69 | val totalSpending = transactionBasketRepository.totalSpentLong() 70 | 71 | if (totalSpending is Data.Loaded) { 72 | AppDatabase.saveDbBackup( 73 | context = context, 74 | totalTransactions = totalTransactions, 75 | totalSpending = totalSpending.data ?: 0, 76 | ) 77 | } 78 | refreshAvailableBackups() 79 | } 80 | } 81 | 82 | /** 83 | * Loads a backup of the database 84 | * @param dbFile Database file to load 85 | */ 86 | fun loadDbBackup(dbFile: DatabaseBackup) = viewModelScope.launch { 87 | AppDatabase.loadDbBackup( 88 | context, 89 | dbFile, 90 | ) 91 | } 92 | 93 | /** 94 | * Removes a backup of the database 95 | * @param dbFile Database file to remove 96 | */ 97 | fun deleteDbBackup(dbFile: DatabaseBackup) = viewModelScope.launch { 98 | AppDatabase.deleteDbBackup(dbFile) 99 | refreshAvailableBackups() 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/display/category/CategoryRoute.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.display.category 2 | 3 | 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.LaunchedEffect 6 | import androidx.compose.runtime.collectAsState 7 | import androidx.paging.compose.collectAsLazyPagingItems 8 | import com.kssidll.arru.domain.data.Data 9 | import dev.olshevski.navigation.reimagined.hilt.hiltViewModel 10 | 11 | @Composable 12 | fun CategoryRoute( 13 | categoryId: Long, 14 | navigateBack: () -> Unit, 15 | navigateCategoryEdit: () -> Unit, 16 | navigateProduct: (productId: Long) -> Unit, 17 | navigateItemEdit: (itemId: Long) -> Unit, 18 | navigateProducer: (producerId: Long) -> Unit, 19 | navigateShop: (shopId: Long) -> Unit, 20 | ) { 21 | val viewModel: CategoryViewModel = hiltViewModel() 22 | 23 | LaunchedEffect(categoryId) { 24 | if (!viewModel.performDataUpdate(categoryId)) { 25 | navigateBack() 26 | } 27 | } 28 | 29 | CategoryScreen( 30 | onBack = navigateBack, 31 | category = viewModel.category, 32 | transactionItems = viewModel.transactions() 33 | .collectAsLazyPagingItems(), 34 | spentByTimeData = viewModel.spentByTimeData?.collectAsState(initial = Data.Loading())?.value 35 | ?: Data.Loaded(emptyList()), 36 | totalSpentData = viewModel.categoryTotalSpent() 37 | ?.collectAsState(initial = Data.Loading())?.value ?: Data.Loading(), 38 | spentByTimePeriod = viewModel.spentByTimePeriod, 39 | onSpentByTimePeriodSwitch = { 40 | viewModel.switchPeriod(it) 41 | }, 42 | chartEntryModelProducer = viewModel.chartEntryModelProducer, 43 | onItemClick = navigateProduct, 44 | onItemProducerClick = navigateProducer, 45 | onItemShopClick = navigateShop, 46 | onItemLongClick = navigateItemEdit, 47 | onEditAction = navigateCategoryEdit, 48 | ) 49 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/display/producer/ProducerRoute.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.display.producer 2 | 3 | 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.LaunchedEffect 6 | import androidx.compose.runtime.collectAsState 7 | import androidx.paging.compose.collectAsLazyPagingItems 8 | import com.kssidll.arru.domain.data.Data 9 | import dev.olshevski.navigation.reimagined.hilt.hiltViewModel 10 | 11 | @Composable 12 | fun ProducerRoute( 13 | producerId: Long, 14 | navigateBack: () -> Unit, 15 | navigateProduct: (productId: Long) -> Unit, 16 | navigateCategory: (categoryId: Long) -> Unit, 17 | navigateShop: (shopId: Long) -> Unit, 18 | navigateItemEdit: (itemId: Long) -> Unit, 19 | navigateProducerEdit: () -> Unit, 20 | ) { 21 | val viewModel: ProducerViewModel = hiltViewModel() 22 | 23 | LaunchedEffect(producerId) { 24 | if (!viewModel.performDataUpdate(producerId)) { 25 | navigateBack() 26 | } 27 | } 28 | 29 | ProducerScreen( 30 | onBack = navigateBack, 31 | producer = viewModel.producer, 32 | transactionItems = viewModel.transactions() 33 | .collectAsLazyPagingItems(), 34 | spentByTimeData = viewModel.spentByTimeData?.collectAsState(initial = Data.Loading())?.value 35 | ?: Data.Loaded(emptyList()), 36 | totalSpentData = viewModel.producerTotalSpent() 37 | ?.collectAsState(initial = Data.Loading())?.value ?: Data.Loaded(0f), 38 | spentByTimePeriod = viewModel.spentByTimePeriod, 39 | onSpentByTimePeriodSwitch = { 40 | viewModel.switchPeriod(it) 41 | }, 42 | chartEntryModelProducer = viewModel.chartEntryModelProducer, 43 | onItemClick = navigateProduct, 44 | onItemCategoryClick = navigateCategory, 45 | onItemShopClick = navigateShop, 46 | onItemLongClick = navigateItemEdit, 47 | onEditAction = navigateProducerEdit, 48 | ) 49 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/display/product/ProductRoute.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.display.product 2 | 3 | 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.LaunchedEffect 6 | import androidx.compose.runtime.collectAsState 7 | import androidx.paging.compose.collectAsLazyPagingItems 8 | import com.kssidll.arru.domain.data.Data 9 | import dev.olshevski.navigation.reimagined.hilt.hiltViewModel 10 | 11 | @Composable 12 | fun ProductRoute( 13 | productId: Long, 14 | navigateBack: () -> Unit, 15 | navigateCategory: (categoryId: Long) -> Unit, 16 | navigateProducer: (producerId: Long) -> Unit, 17 | navigateShop: (shopId: Long) -> Unit, 18 | navigateItemEdit: (itemId: Long) -> Unit, 19 | navigateProductEdit: () -> Unit, 20 | ) { 21 | val viewModel: ProductViewModel = hiltViewModel() 22 | 23 | LaunchedEffect(productId) { 24 | if (!viewModel.performDataUpdate(productId)) { 25 | navigateBack() 26 | } 27 | } 28 | 29 | ProductScreen( 30 | onBack = navigateBack, 31 | product = viewModel.product, 32 | transactionItems = viewModel.transactions() 33 | .collectAsLazyPagingItems(), 34 | spentByTimeData = viewModel.spentByTimeData?.collectAsState(initial = Data.Loading())?.value 35 | ?: Data.Loaded(emptyList()), 36 | productPriceByShopByTimeData = viewModel.productPriceByShop() 37 | ?.collectAsState(initial = Data.Loading())?.value ?: Data.Loaded(emptyList()), 38 | totalSpentData = viewModel.productTotalSpent() 39 | ?.collectAsState(initial = Data.Loading())?.value ?: Data.Loaded(0F), 40 | spentByTimePeriod = viewModel.spentByTimePeriod, 41 | onSpentByTimePeriodSwitch = { 42 | viewModel.switchPeriod(it) 43 | }, 44 | chartEntryModelProducer = viewModel.chartEntryModelProducer, 45 | onItemCategoryClick = navigateCategory, 46 | onItemProducerClick = navigateProducer, 47 | onItemShopClick = navigateShop, 48 | onItemLongClick = navigateItemEdit, 49 | onEditAction = navigateProductEdit, 50 | ) 51 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/display/shop/ShopRoute.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.display.shop 2 | 3 | 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.LaunchedEffect 6 | import androidx.compose.runtime.collectAsState 7 | import androidx.paging.compose.collectAsLazyPagingItems 8 | import com.kssidll.arru.domain.data.Data 9 | import dev.olshevski.navigation.reimagined.hilt.hiltViewModel 10 | 11 | @Composable 12 | fun ShopRoute( 13 | shopId: Long, 14 | navigateBack: () -> Unit, 15 | navigateProduct: (productId: Long) -> Unit, 16 | navigateCategory: (categoryId: Long) -> Unit, 17 | navigateProducer: (producerId: Long) -> Unit, 18 | navigateItemEdit: (itemId: Long) -> Unit, 19 | navigateShopEdit: () -> Unit, 20 | ) { 21 | val viewModel: ShopViewModel = hiltViewModel() 22 | 23 | LaunchedEffect(shopId) { 24 | if (!viewModel.performDataUpdate(shopId)) { 25 | navigateBack() 26 | } 27 | } 28 | 29 | ShopScreen( 30 | onBack = navigateBack, 31 | shop = viewModel.shop, 32 | transactionItems = viewModel.transactions() 33 | .collectAsLazyPagingItems(), 34 | spentByTimeData = viewModel.spentByTimeData?.collectAsState(initial = Data.Loading())?.value 35 | ?: Data.Loaded(emptyList()), 36 | totalSpentData = viewModel.shopTotalSpent() 37 | ?.collectAsState(initial = Data.Loading())?.value ?: Data.Loaded(0F), 38 | spentByTimePeriod = viewModel.spentByTimePeriod, 39 | onSpentByTimePeriodSwitch = { 40 | viewModel.switchPeriod(it) 41 | }, 42 | chartEntryModelProducer = viewModel.chartEntryModelProducer, 43 | onItemClick = navigateProduct, 44 | onItemCategoryClick = navigateCategory, 45 | onItemProducerClick = navigateProducer, 46 | onItemLongClick = navigateItemEdit, 47 | onEditAction = navigateShopEdit, 48 | ) 49 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/display/transaction/TransactionRoute.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.display.transaction 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.collectAsState 5 | import com.kssidll.arru.domain.data.Data 6 | import dev.olshevski.navigation.reimagined.hilt.hiltViewModel 7 | 8 | @Composable 9 | fun TransactionRoute( 10 | transactionId: Long, 11 | navigateBack: () -> Unit, 12 | navigateTransactionEdit: (transactionId: Long) -> Unit, 13 | navigateItemAdd: (transactionId: Long) -> Unit, 14 | navigateProduct: (productId: Long) -> Unit, 15 | navigateItemEdit: (itemId: Long) -> Unit, 16 | navigateCategory: (categoryId: Long) -> Unit, 17 | navigateProducer: (producerId: Long) -> Unit, 18 | navigateShop: (shopId: Long) -> Unit, 19 | ) { 20 | val viewModel: TransactionViewModel = hiltViewModel() 21 | 22 | TransactionScreen( 23 | onBack = navigateBack, 24 | transaction = viewModel.transaction(transactionId) 25 | .collectAsState(initial = Data.Loading()).value, 26 | onEditAction = { 27 | navigateTransactionEdit(transactionId) 28 | }, 29 | onItemAddClick = navigateItemAdd, 30 | onItemClick = navigateProduct, 31 | onItemLongClick = navigateItemEdit, 32 | onItemCategoryClick = navigateCategory, 33 | onItemProducerClick = navigateProducer, 34 | onItemShopClick = navigateShop, 35 | ) 36 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/display/transaction/TransactionViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.display.transaction 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.kssidll.arru.data.data.TransactionBasketWithItems 5 | import com.kssidll.arru.data.repository.TransactionBasketRepositorySource 6 | import com.kssidll.arru.domain.data.Data 7 | import dagger.hilt.android.lifecycle.HiltViewModel 8 | import kotlinx.coroutines.flow.Flow 9 | import javax.inject.Inject 10 | 11 | @HiltViewModel 12 | class TransactionViewModel @Inject constructor( 13 | private val transactionRepository: TransactionBasketRepositorySource, 14 | ): ViewModel() { 15 | fun transaction(transactionId: Long): Flow> { 16 | return transactionRepository.transactionBasketWithItemsFlow(transactionId) 17 | } 18 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/home/component/AnalysisDateHeader.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.home.component 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.size 8 | import androidx.compose.foundation.layout.widthIn 9 | import androidx.compose.material.icons.Icons 10 | import androidx.compose.material.icons.filled.KeyboardDoubleArrowLeft 11 | import androidx.compose.material.icons.filled.KeyboardDoubleArrowRight 12 | import androidx.compose.material3.Icon 13 | import androidx.compose.material3.IconButton 14 | import androidx.compose.material3.Surface 15 | import androidx.compose.material3.Text 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.ui.Alignment 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.tooling.preview.PreviewLightDark 20 | import androidx.compose.ui.unit.dp 21 | import com.kssidll.arru.ui.theme.ArrugarqTheme 22 | import com.kssidll.arru.ui.theme.Typography 23 | import java.text.SimpleDateFormat 24 | import java.util.Calendar 25 | import java.util.Locale 26 | 27 | /** 28 | * @param year Year for which the main data is fetched 29 | * @param month Month for which the main data is fetched, in range of 1 - 12 30 | * @param onMonthIncrement Callback called to request [month] increment, should handle overflow and increase year 31 | * @param onMonthDecrement Callback called to request [month] decrement, should handle underflow and decrease year 32 | */ 33 | @Composable 34 | fun AnalysisDateHeader( 35 | year: Int, 36 | month: Int, 37 | onMonthIncrement: () -> Unit, 38 | onMonthDecrement: () -> Unit, 39 | modifier: Modifier = Modifier 40 | ) { 41 | Row( 42 | horizontalArrangement = Arrangement.Center, 43 | verticalAlignment = Alignment.CenterVertically, 44 | modifier = modifier.fillMaxWidth() 45 | ) { 46 | val cal = Calendar.getInstance() 47 | cal.clear() 48 | cal.set( 49 | Calendar.MONTH, 50 | month - 1 51 | ) // calendar has 0 - 11 months 52 | 53 | IconButton(onClick = onMonthDecrement) { 54 | Icon( 55 | imageVector = Icons.Default.KeyboardDoubleArrowLeft, 56 | contentDescription = null, 57 | modifier = Modifier.size(30.dp) 58 | ) 59 | } 60 | 61 | Column( 62 | horizontalAlignment = Alignment.CenterHorizontally, 63 | modifier = Modifier.widthIn(min = 188.dp) 64 | ) { 65 | Text( 66 | text = SimpleDateFormat( 67 | "LLLL", 68 | Locale.getDefault() 69 | ).format(cal.time) 70 | .replaceFirstChar { it.titlecase() }, 71 | style = Typography.headlineLarge, 72 | ) 73 | Text( 74 | text = year.toString(), 75 | style = Typography.titleMedium, 76 | ) 77 | } 78 | 79 | IconButton(onClick = onMonthIncrement) { 80 | Icon( 81 | imageVector = Icons.Default.KeyboardDoubleArrowRight, 82 | contentDescription = null, 83 | modifier = Modifier.size(30.dp) 84 | ) 85 | } 86 | } 87 | } 88 | 89 | @PreviewLightDark 90 | @Composable 91 | private fun AnalysisDateHeaderPreview() { 92 | ArrugarqTheme { 93 | Surface { 94 | AnalysisDateHeader( 95 | year = 2021, 96 | month = 12, 97 | onMonthIncrement = {}, 98 | onMonthDecrement = {}, 99 | ) 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/modify/category/ModifyCategoryViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.modify.category 2 | 3 | import androidx.compose.runtime.MutableState 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.lifecycle.ViewModel 6 | import com.kssidll.arru.data.repository.CategoryRepositorySource 7 | import com.kssidll.arru.domain.data.Field 8 | import com.kssidll.arru.ui.screen.modify.ModifyScreenState 9 | 10 | /** 11 | * Base [ViewModel] class for Category modification view models 12 | * @property screenState A [ModifyCategoryScreenState] instance to use as screen state representation 13 | */ 14 | abstract class ModifyCategoryViewModel: ViewModel() { 15 | protected abstract val categoryRepository: CategoryRepositorySource 16 | internal val screenState: ModifyCategoryScreenState = ModifyCategoryScreenState() 17 | } 18 | 19 | /** 20 | * Data representing [ModifyCategoryScreenImpl] screen state 21 | */ 22 | data class ModifyCategoryScreenState( 23 | val name: MutableState> = mutableStateOf(Field.Loaded(String())), 24 | ): ModifyScreenState() -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/modify/category/addcategory/AddCategoryRoute.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.modify.category.addcategory 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.LaunchedEffect 5 | import androidx.compose.runtime.rememberCoroutineScope 6 | import com.kssidll.arru.domain.data.Field 7 | import com.kssidll.arru.ui.screen.modify.category.ModifyCategoryScreenImpl 8 | import dev.olshevski.navigation.reimagined.hilt.hiltViewModel 9 | import kotlinx.coroutines.launch 10 | 11 | @Composable 12 | fun AddCategoryRoute( 13 | defaultName: String?, 14 | navigateBack: (categoryId: Long?) -> Unit, 15 | ) { 16 | val scope = rememberCoroutineScope() 17 | val viewModel: AddCategoryViewModel = hiltViewModel() 18 | 19 | LaunchedEffect(Unit) { 20 | viewModel.screenState.name.value = Field.Loaded(defaultName) 21 | } 22 | 23 | ModifyCategoryScreenImpl( 24 | onBack = { 25 | navigateBack(null) 26 | }, 27 | state = viewModel.screenState, 28 | onSubmit = { 29 | scope.launch { 30 | val result = viewModel.addCategory() 31 | if (result.isNotError()) { 32 | navigateBack(result.id) 33 | } 34 | } 35 | }, 36 | ) 37 | } 38 | 39 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/modify/category/addcategory/AddCategoryViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.modify.category.addcategory 2 | 3 | import androidx.lifecycle.viewModelScope 4 | import com.kssidll.arru.data.repository.CategoryRepositorySource 5 | import com.kssidll.arru.data.repository.CategoryRepositorySource.Companion.InsertResult 6 | import com.kssidll.arru.domain.data.FieldError 7 | import com.kssidll.arru.ui.screen.modify.category.ModifyCategoryViewModel 8 | import dagger.hilt.android.lifecycle.HiltViewModel 9 | import kotlinx.coroutines.async 10 | import javax.inject.Inject 11 | 12 | @HiltViewModel 13 | class AddCategoryViewModel @Inject constructor( 14 | override val categoryRepository: CategoryRepositorySource, 15 | ): ModifyCategoryViewModel() { 16 | 17 | /** 18 | * Tries to add a product category to the repository 19 | * @return resulting [InsertResult] 20 | */ 21 | suspend fun addCategory(): InsertResult = viewModelScope.async { 22 | screenState.attemptedToSubmit.value = true 23 | 24 | val result = categoryRepository.insert(screenState.name.value.data.orEmpty()) 25 | 26 | if (result.isError()) { 27 | when (result.error!!) { 28 | is InsertResult.InvalidName -> { 29 | screenState.name.apply { 30 | value = value.toError(FieldError.InvalidValueError) 31 | } 32 | } 33 | 34 | is InsertResult.DuplicateName -> { 35 | screenState.name.apply { 36 | value = value.toError(FieldError.DuplicateValueError) 37 | } 38 | } 39 | } 40 | } 41 | 42 | return@async result 43 | } 44 | .await() 45 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/modify/category/editcategory/EditCategoryRoute.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.modify.category.editcategory 2 | 3 | 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.LaunchedEffect 6 | import androidx.compose.runtime.rememberCoroutineScope 7 | import androidx.compose.ui.res.stringResource 8 | import com.kssidll.arru.R 9 | import com.kssidll.arru.ui.screen.modify.category.ModifyCategoryScreenImpl 10 | import dev.olshevski.navigation.reimagined.hilt.hiltViewModel 11 | import kotlinx.coroutines.launch 12 | 13 | @Composable 14 | fun EditCategoryRoute( 15 | categoryId: Long, 16 | navigateBack: () -> Unit, 17 | navigateBackDelete: () -> Unit, 18 | ) { 19 | val scope = rememberCoroutineScope() 20 | 21 | val viewModel: EditCategoryViewModel = hiltViewModel() 22 | 23 | LaunchedEffect(categoryId) { 24 | if (!viewModel.updateState(categoryId)) { 25 | navigateBack() 26 | } 27 | } 28 | 29 | ModifyCategoryScreenImpl( 30 | onBack = navigateBack, 31 | state = viewModel.screenState, 32 | onSubmit = { 33 | scope.launch { 34 | if (viewModel.updateCategory(categoryId) 35 | .isNotError() 36 | ) { 37 | navigateBack() 38 | } 39 | } 40 | }, 41 | onDelete = { 42 | scope.launch { 43 | if (viewModel.deleteCategory(categoryId) 44 | .isNotError() 45 | ) { 46 | navigateBackDelete() 47 | } 48 | } 49 | }, 50 | onMerge = { 51 | scope.launch { 52 | if (viewModel.mergeWith(it) 53 | .isNotError() 54 | ) { 55 | navigateBackDelete() 56 | } 57 | } 58 | }, 59 | mergeCandidates = viewModel.allMergeCandidates(categoryId), 60 | mergeConfirmMessageTemplate = stringResource(id = R.string.merge_action_message_template) 61 | .replace( 62 | "{value_1}", 63 | viewModel.mergeMessageCategoryName 64 | ), 65 | 66 | chosenMergeCandidate = viewModel.chosenMergeCandidate.value, 67 | onChosenMergeCandidateChange = { 68 | viewModel.chosenMergeCandidate.apply { value = it } 69 | }, 70 | showMergeConfirmDialog = viewModel.showMergeConfirmDialog.value, 71 | onShowMergeConfirmDialogChange = { 72 | viewModel.showMergeConfirmDialog.apply { value = it } 73 | }, 74 | submitButtonText = stringResource(id = R.string.item_product_category_edit), 75 | ) 76 | } 77 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/modify/item/additem/AddItemRoute.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.modify.item.additem 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.LaunchedEffect 5 | import androidx.compose.runtime.collectAsState 6 | import androidx.compose.runtime.rememberCoroutineScope 7 | import com.kssidll.arru.domain.data.Data 8 | import com.kssidll.arru.ui.screen.modify.item.ModifyItemScreenImpl 9 | import dev.olshevski.navigation.reimagined.hilt.hiltViewModel 10 | import kotlinx.coroutines.launch 11 | 12 | @Composable 13 | fun AddItemRoute( 14 | transactionId: Long, 15 | navigateBack: () -> Unit, 16 | navigateProductAdd: (query: String?) -> Unit, 17 | navigateVariantAdd: (productId: Long, query: String?) -> Unit, 18 | navigateProductEdit: (productId: Long) -> Unit, 19 | navigateVariantEdit: (variantId: Long) -> Unit, 20 | providedProductId: Long?, 21 | providedVariantId: Long?, 22 | ) { 23 | val scope = rememberCoroutineScope() 24 | val viewModel: AddItemViewModel = hiltViewModel() 25 | 26 | LaunchedEffect( 27 | providedProductId, 28 | providedVariantId 29 | ) { 30 | viewModel.setSelectedProductToProvided( 31 | providedProductId, 32 | providedVariantId 33 | ) 34 | } 35 | 36 | ModifyItemScreenImpl( 37 | onBack = navigateBack, 38 | state = viewModel.screenState, 39 | products = viewModel.allProducts() 40 | .collectAsState(initial = Data.Loading()).value, 41 | variants = viewModel.productVariants.collectAsState(initial = Data.Loading()).value, 42 | onNewProductSelected = { 43 | scope.launch { 44 | viewModel.onNewProductSelected(it) 45 | } 46 | }, 47 | onNewVariantSelected = { 48 | viewModel.onNewVariantSelected(it) 49 | }, 50 | onSubmit = { 51 | scope.launch { 52 | if (viewModel.addItem(transactionId) 53 | .isNotError() 54 | ) { 55 | navigateBack() 56 | } 57 | } 58 | }, 59 | onProductAddButtonClick = navigateProductAdd, 60 | onVariantAddButtonClick = navigateVariantAdd, 61 | onItemLongClick = navigateProductEdit, 62 | onItemVariantLongClick = navigateVariantEdit, 63 | ) 64 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/modify/item/additem/AddItemViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.modify.item.additem 2 | 3 | import android.util.Log 4 | import androidx.lifecycle.viewModelScope 5 | import com.kssidll.arru.data.data.Item 6 | import com.kssidll.arru.data.data.TransactionBasket 7 | import com.kssidll.arru.data.repository.ItemRepositorySource 8 | import com.kssidll.arru.data.repository.ItemRepositorySource.Companion.InsertResult 9 | import com.kssidll.arru.data.repository.ProductRepositorySource 10 | import com.kssidll.arru.data.repository.VariantRepositorySource 11 | import com.kssidll.arru.domain.data.FieldError 12 | import com.kssidll.arru.ui.screen.modify.item.ModifyItemViewModel 13 | import dagger.hilt.android.lifecycle.HiltViewModel 14 | import kotlinx.coroutines.async 15 | import javax.inject.Inject 16 | 17 | @HiltViewModel 18 | class AddItemViewModel @Inject constructor( 19 | override val itemRepository: ItemRepositorySource, 20 | override val productRepository: ProductRepositorySource, 21 | override val variantsRepository: VariantRepositorySource, 22 | ): ModifyItemViewModel() { 23 | 24 | init { 25 | loadLastItem() 26 | } 27 | 28 | /** 29 | * Tries to add an item to the repository 30 | * @param transactionId id of the [TransactionBasket] to add the item to 31 | * @return resulting [InsertResult] 32 | */ 33 | suspend fun addItem(transactionId: Long) = viewModelScope.async { 34 | screenState.attemptedToSubmit.value = true 35 | 36 | val result = itemRepository.insert( 37 | transactionId = transactionId, 38 | productId = screenState.selectedProduct.value.data?.id ?: Item.INVALID_PRODUCT_ID, 39 | variantId = screenState.selectedVariant.value.data?.id, 40 | quantity = screenState.quantity.value.data?.let { Item.quantityFromString(it) } 41 | ?: Item.INVALID_QUANTITY, 42 | price = screenState.price.value.data?.let { Item.priceFromString(it) } 43 | ?: Item.INVALID_PRICE, 44 | ) 45 | 46 | if (result.isError()) { 47 | when (result.error!!) { 48 | is InsertResult.InvalidTransactionId -> { 49 | Log.e( 50 | "InvalidId", 51 | "Tried inserting an item to a transaction that doesn't exist in AddItemViewModel" 52 | ) 53 | return@async InsertResult.Success(-1) 54 | } 55 | 56 | is InsertResult.InvalidProductId -> { 57 | screenState.selectedProduct.apply { 58 | value = value.toError(FieldError.InvalidValueError) 59 | } 60 | } 61 | 62 | is InsertResult.InvalidVariantId -> { 63 | screenState.selectedVariant.apply { 64 | value = value.toError(FieldError.InvalidValueError) 65 | } 66 | } 67 | 68 | is InsertResult.InvalidQuantity -> { 69 | screenState.quantity.apply { 70 | value = value.toError(FieldError.InvalidValueError) 71 | } 72 | } 73 | 74 | is InsertResult.InvalidPrice -> { 75 | screenState.price.apply { 76 | value = value.toError(FieldError.InvalidValueError) 77 | } 78 | } 79 | } 80 | } 81 | 82 | return@async result 83 | } 84 | .await() 85 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/modify/item/edititem/EditItemRoute.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.modify.item.edititem 2 | 3 | 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.LaunchedEffect 6 | import androidx.compose.runtime.collectAsState 7 | import androidx.compose.runtime.rememberCoroutineScope 8 | import androidx.compose.ui.res.stringResource 9 | import com.kssidll.arru.R 10 | import com.kssidll.arru.domain.data.Data 11 | import com.kssidll.arru.ui.screen.modify.item.ModifyItemScreenImpl 12 | import dev.olshevski.navigation.reimagined.hilt.hiltViewModel 13 | import kotlinx.coroutines.launch 14 | 15 | @Composable 16 | fun EditItemRoute( 17 | itemId: Long, 18 | navigateBack: () -> Unit, 19 | navigateBackDelete: () -> Unit, 20 | navigateProductAdd: (query: String?) -> Unit, 21 | navigateVariantAdd: (productId: Long, query: String?) -> Unit, 22 | navigateProductEdit: (productId: Long) -> Unit, 23 | navigateVariantEdit: (variantId: Long) -> Unit, 24 | providedProductId: Long?, 25 | providedVariantId: Long?, 26 | ) { 27 | val scope = rememberCoroutineScope() 28 | 29 | val viewModel: EditItemViewModel = hiltViewModel() 30 | 31 | LaunchedEffect(itemId) { 32 | if (!viewModel.updateState(itemId)) { 33 | navigateBack() 34 | } 35 | } 36 | 37 | LaunchedEffect( 38 | providedProductId, 39 | providedVariantId 40 | ) { 41 | viewModel.setSelectedProductToProvided( 42 | providedProductId, 43 | providedVariantId 44 | ) 45 | } 46 | 47 | ModifyItemScreenImpl( 48 | onBack = navigateBack, 49 | state = viewModel.screenState, 50 | products = viewModel.allProducts() 51 | .collectAsState(initial = Data.Loading()).value, 52 | variants = viewModel.productVariants.collectAsState(initial = Data.Loading()).value, 53 | onNewProductSelected = { 54 | scope.launch { 55 | viewModel.onNewProductSelected(it) 56 | } 57 | }, 58 | onNewVariantSelected = { 59 | viewModel.onNewVariantSelected(it) 60 | }, 61 | onSubmit = { 62 | scope.launch { 63 | if (viewModel.updateItem(itemId) 64 | .isNotError() 65 | ) { 66 | navigateBack() 67 | } 68 | } 69 | }, 70 | onDelete = { 71 | scope.launch { 72 | if (viewModel.deleteItem(itemId) 73 | .isNotError() 74 | ) { 75 | navigateBackDelete() 76 | } 77 | } 78 | }, 79 | submitButtonText = stringResource(id = R.string.item_edit), 80 | onProductAddButtonClick = navigateProductAdd, 81 | onVariantAddButtonClick = navigateVariantAdd, 82 | onItemLongClick = navigateProductEdit, 83 | onItemVariantLongClick = navigateVariantEdit, 84 | ) 85 | } 86 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/modify/producer/ModifyProducerViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.modify.producer 2 | 3 | import androidx.compose.runtime.MutableState 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.lifecycle.ViewModel 6 | import com.kssidll.arru.data.repository.ProducerRepositorySource 7 | import com.kssidll.arru.domain.data.Field 8 | import com.kssidll.arru.ui.screen.modify.ModifyScreenState 9 | 10 | /** 11 | * Base [ViewModel] class for Producer modification view models 12 | * @property screenState A [ModifyProducerScreenState] instance to use as screen state representation 13 | */ 14 | abstract class ModifyProducerViewModel: ViewModel() { 15 | protected abstract val producerRepository: ProducerRepositorySource 16 | internal val screenState: ModifyProducerScreenState = ModifyProducerScreenState() 17 | } 18 | 19 | /** 20 | * Data representing [ModifyProducerScreenImpl] screen state 21 | */ 22 | data class ModifyProducerScreenState( 23 | val name: MutableState> = mutableStateOf(Field.Loaded()) 24 | ): ModifyScreenState() -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/modify/producer/addproducer/AddProducerRoute.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.modify.producer.addproducer 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.LaunchedEffect 5 | import androidx.compose.runtime.rememberCoroutineScope 6 | import com.kssidll.arru.domain.data.Field 7 | import com.kssidll.arru.ui.screen.modify.producer.ModifyProducerScreenImpl 8 | import dev.olshevski.navigation.reimagined.hilt.hiltViewModel 9 | import kotlinx.coroutines.launch 10 | 11 | @Composable 12 | fun AddProducerRoute( 13 | defaultName: String?, 14 | navigateBack: (producerId: Long?) -> Unit, 15 | ) { 16 | val scope = rememberCoroutineScope() 17 | val viewModel: AddProducerViewModel = hiltViewModel() 18 | 19 | LaunchedEffect(Unit) { 20 | viewModel.screenState.name.value = Field.Loaded(defaultName) 21 | } 22 | 23 | ModifyProducerScreenImpl( 24 | onBack = { 25 | navigateBack(null) 26 | }, 27 | state = viewModel.screenState, 28 | onSubmit = { 29 | scope.launch { 30 | val result = viewModel.addProducer() 31 | if (result.isNotError()) { 32 | navigateBack(result.id) 33 | } 34 | } 35 | } 36 | ) 37 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/modify/producer/addproducer/AddProducerViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.modify.producer.addproducer 2 | 3 | import androidx.lifecycle.viewModelScope 4 | import com.kssidll.arru.data.repository.ProducerRepositorySource 5 | import com.kssidll.arru.data.repository.ProducerRepositorySource.Companion.InsertResult 6 | import com.kssidll.arru.domain.data.FieldError 7 | import com.kssidll.arru.ui.screen.modify.producer.ModifyProducerViewModel 8 | import dagger.hilt.android.lifecycle.HiltViewModel 9 | import kotlinx.coroutines.async 10 | import javax.inject.Inject 11 | 12 | @HiltViewModel 13 | class AddProducerViewModel @Inject constructor( 14 | override val producerRepository: ProducerRepositorySource, 15 | ): ModifyProducerViewModel() { 16 | 17 | /** 18 | * Tries to add a product producer to the repository 19 | * @return resulting [InsertResult] 20 | */ 21 | suspend fun addProducer() = viewModelScope.async { 22 | screenState.attemptedToSubmit.value = true 23 | 24 | val result = producerRepository.insert(screenState.name.value.data.orEmpty()) 25 | 26 | if (result.isError()) { 27 | when (result.error!!) { 28 | InsertResult.InvalidName -> { 29 | screenState.name.apply { 30 | value = value.toError(FieldError.InvalidValueError) 31 | } 32 | } 33 | 34 | InsertResult.DuplicateName -> { 35 | screenState.name.apply { 36 | value = value.toError(FieldError.DuplicateValueError) 37 | } 38 | } 39 | } 40 | } 41 | 42 | return@async result 43 | } 44 | .await() 45 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/modify/producer/editproducer/EditProducerRoute.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.modify.producer.editproducer 2 | 3 | 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.LaunchedEffect 6 | import androidx.compose.runtime.rememberCoroutineScope 7 | import androidx.compose.ui.res.stringResource 8 | import com.kssidll.arru.R 9 | import com.kssidll.arru.ui.screen.modify.producer.ModifyProducerScreenImpl 10 | import dev.olshevski.navigation.reimagined.hilt.hiltViewModel 11 | import kotlinx.coroutines.launch 12 | 13 | @Composable 14 | fun EditProducerRoute( 15 | producerId: Long, 16 | navigateBack: () -> Unit, 17 | navigateBackDelete: () -> Unit, 18 | ) { 19 | val scope = rememberCoroutineScope() 20 | 21 | val viewModel: EditProducerViewModel = hiltViewModel() 22 | 23 | LaunchedEffect(producerId) { 24 | if (!viewModel.updateState(producerId)) { 25 | navigateBack() 26 | } 27 | } 28 | 29 | ModifyProducerScreenImpl( 30 | onBack = navigateBack, 31 | state = viewModel.screenState, 32 | onSubmit = { 33 | scope.launch { 34 | if (viewModel.updateProducer(producerId) 35 | .isNotError() 36 | ) { 37 | navigateBack() 38 | } 39 | } 40 | }, 41 | onDelete = { 42 | scope.launch { 43 | if (viewModel.deleteProducer(producerId) 44 | .isNotError() 45 | ) { 46 | navigateBackDelete() 47 | } 48 | } 49 | }, 50 | onMerge = { 51 | scope.launch { 52 | if (viewModel.mergeWith(it) 53 | .isNotError() 54 | ) { 55 | navigateBackDelete() 56 | } 57 | } 58 | }, 59 | mergeCandidates = viewModel.allMergeCandidates(producerId), 60 | mergeConfirmMessageTemplate = stringResource(id = R.string.merge_action_message_template) 61 | .replace( 62 | "{value_1}", 63 | viewModel.mergeMessageProducerName 64 | ), 65 | 66 | chosenMergeCandidate = viewModel.chosenMergeCandidate.value, 67 | onChosenMergeCandidateChange = { 68 | viewModel.chosenMergeCandidate.apply { value = it } 69 | }, 70 | showMergeConfirmDialog = viewModel.showMergeConfirmDialog.value, 71 | onShowMergeConfirmDialogChange = { 72 | viewModel.showMergeConfirmDialog.apply { value = it } 73 | }, 74 | submitButtonText = stringResource(id = R.string.item_product_producer_edit), 75 | ) 76 | } 77 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/modify/product/addproduct/AddProductRoute.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.modify.product.addproduct 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.LaunchedEffect 5 | import androidx.compose.runtime.collectAsState 6 | import androidx.compose.runtime.rememberCoroutineScope 7 | import com.kssidll.arru.domain.data.Data 8 | import com.kssidll.arru.domain.data.Field 9 | import com.kssidll.arru.ui.screen.modify.product.ModifyProductScreenImpl 10 | import dev.olshevski.navigation.reimagined.hilt.hiltViewModel 11 | import kotlinx.coroutines.launch 12 | 13 | @Composable 14 | fun AddProductRoute( 15 | defaultName: String?, 16 | navigateBack: (productId: Long?) -> Unit, 17 | navigateCategoryAdd: (query: String?) -> Unit, 18 | navigateProducerAdd: (query: String?) -> Unit, 19 | navigateCategoryEdit: (categoryId: Long) -> Unit, 20 | navigateProducerEdit: (producerId: Long) -> Unit, 21 | providedProducerId: Long?, 22 | providedCategoryId: Long?, 23 | ) { 24 | val scope = rememberCoroutineScope() 25 | val viewModel: AddProductViewModel = hiltViewModel() 26 | 27 | LaunchedEffect(Unit) { 28 | viewModel.screenState.name.value = Field.Loaded(defaultName) 29 | } 30 | 31 | LaunchedEffect(providedProducerId) { 32 | viewModel.setSelectedProducer(providedProducerId) 33 | } 34 | 35 | LaunchedEffect(providedCategoryId) { 36 | viewModel.setSelectedCategory(providedCategoryId) 37 | } 38 | 39 | ModifyProductScreenImpl( 40 | onBack = { 41 | navigateBack(null) 42 | }, 43 | state = viewModel.screenState, 44 | categories = viewModel.allCategories() 45 | .collectAsState(initial = Data.Loading()).value, 46 | producers = viewModel.allProducers() 47 | .collectAsState(initial = Data.Loading()).value, 48 | onNewProducerSelected = { 49 | viewModel.onNewProducerSelected(it) 50 | }, 51 | onNewCategorySelected = { 52 | viewModel.onNewCategorySelected(it) 53 | }, 54 | onSubmit = { 55 | scope.launch { 56 | val result = viewModel.addProduct() 57 | if (result.isNotError()) { 58 | navigateBack(result.id) 59 | } 60 | } 61 | }, 62 | onCategoryAddButtonClick = navigateCategoryAdd, 63 | onProducerAddButtonClick = navigateProducerAdd, 64 | onItemCategoryLongClick = navigateCategoryEdit, 65 | onItemProducerLongClick = navigateProducerEdit, 66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/modify/product/addproduct/AddProductViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.modify.product.addproduct 2 | 3 | import androidx.lifecycle.viewModelScope 4 | import com.kssidll.arru.data.data.Product 5 | import com.kssidll.arru.data.repository.CategoryRepositorySource 6 | import com.kssidll.arru.data.repository.ProducerRepositorySource 7 | import com.kssidll.arru.data.repository.ProductRepositorySource 8 | import com.kssidll.arru.data.repository.ProductRepositorySource.Companion.InsertResult 9 | import com.kssidll.arru.domain.data.FieldError 10 | import com.kssidll.arru.ui.screen.modify.product.ModifyProductViewModel 11 | import dagger.hilt.android.lifecycle.HiltViewModel 12 | import kotlinx.coroutines.async 13 | import javax.inject.Inject 14 | 15 | @HiltViewModel 16 | class AddProductViewModel @Inject constructor( 17 | override val productRepository: ProductRepositorySource, 18 | override val categoryRepository: CategoryRepositorySource, 19 | override val producerRepository: ProducerRepositorySource, 20 | ): ModifyProductViewModel() { 21 | 22 | /** 23 | * Tries to add a product to the repository 24 | * @return resulting [InsertResult] 25 | */ 26 | suspend fun addProduct() = viewModelScope.async { 27 | screenState.attemptedToSubmit.value = true 28 | 29 | val result = productRepository.insert( 30 | name = screenState.name.value.data.orEmpty(), 31 | categoryId = screenState.selectedProductCategory.value.data?.id 32 | ?: Product.INVALID_CATEGORY_ID, 33 | producerId = screenState.selectedProductProducer.value.data?.id 34 | ) 35 | 36 | if (result.isError()) { 37 | when (result.error!!) { 38 | InsertResult.InvalidName -> { 39 | screenState.name.apply { 40 | value = value.toError(FieldError.InvalidValueError) 41 | } 42 | } 43 | 44 | InsertResult.DuplicateName -> { 45 | screenState.name.apply { 46 | value = value.toError(FieldError.DuplicateValueError) 47 | } 48 | } 49 | 50 | InsertResult.InvalidCategoryId -> { 51 | screenState.selectedProductCategory.apply { 52 | value = value.toError(FieldError.InvalidValueError) 53 | } 54 | } 55 | 56 | InsertResult.InvalidProducerId -> { 57 | screenState.selectedProductProducer.apply { 58 | value = value.toError(FieldError.InvalidValueError) 59 | } 60 | } 61 | } 62 | } 63 | 64 | return@async result 65 | } 66 | .await() 67 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/modify/shop/ModifyShopViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.modify.shop 2 | 3 | import androidx.compose.runtime.MutableState 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.lifecycle.ViewModel 6 | import com.kssidll.arru.data.repository.ShopRepositorySource 7 | import com.kssidll.arru.domain.data.Field 8 | import com.kssidll.arru.ui.screen.modify.ModifyScreenState 9 | 10 | /** 11 | * Base [ViewModel] class for Shop modification view models 12 | * @property screenState A [ModifyShopScreenState] instance to use as screen state representation 13 | */ 14 | abstract class ModifyShopViewModel: ViewModel() { 15 | protected abstract val shopRepository: ShopRepositorySource 16 | internal val screenState: ModifyShopScreenState = ModifyShopScreenState() 17 | } 18 | 19 | /** 20 | * Data representing [ModifyShopScreenImpl] screen state 21 | */ 22 | data class ModifyShopScreenState( 23 | val name: MutableState> = mutableStateOf(Field.Loaded()), 24 | ): ModifyScreenState() -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/modify/shop/addshop/AddShopRoute.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.modify.shop.addshop 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.LaunchedEffect 5 | import androidx.compose.runtime.rememberCoroutineScope 6 | import com.kssidll.arru.domain.data.Field 7 | import com.kssidll.arru.ui.screen.modify.shop.ModifyShopScreenImpl 8 | import dev.olshevski.navigation.reimagined.hilt.hiltViewModel 9 | import kotlinx.coroutines.launch 10 | 11 | @Composable 12 | fun AddShopRoute( 13 | defaultName: String?, 14 | navigateBack: (shopId: Long?) -> Unit, 15 | ) { 16 | val scope = rememberCoroutineScope() 17 | val viewModel: AddShopViewModel = hiltViewModel() 18 | 19 | LaunchedEffect(Unit) { 20 | viewModel.screenState.name.value = Field.Loaded(defaultName) 21 | } 22 | 23 | ModifyShopScreenImpl( 24 | onBack = { 25 | navigateBack(null) 26 | }, 27 | state = viewModel.screenState, 28 | onSubmit = { 29 | scope.launch { 30 | val result = viewModel.addShop() 31 | if (result.isNotError()) { 32 | navigateBack(result.id) 33 | } 34 | } 35 | }, 36 | ) 37 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/modify/shop/addshop/AddShopViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.modify.shop.addshop 2 | 3 | import androidx.lifecycle.viewModelScope 4 | import com.kssidll.arru.data.repository.ShopRepositorySource 5 | import com.kssidll.arru.data.repository.ShopRepositorySource.Companion.InsertResult 6 | import com.kssidll.arru.domain.data.FieldError 7 | import com.kssidll.arru.ui.screen.modify.shop.ModifyShopViewModel 8 | import dagger.hilt.android.lifecycle.HiltViewModel 9 | import kotlinx.coroutines.async 10 | import javax.inject.Inject 11 | 12 | @HiltViewModel 13 | class AddShopViewModel @Inject constructor( 14 | override val shopRepository: ShopRepositorySource, 15 | ): ModifyShopViewModel() { 16 | 17 | /** 18 | * Tries to add a shop to the repository 19 | * @return resulting [InsertResult] 20 | */ 21 | suspend fun addShop() = viewModelScope.async { 22 | screenState.attemptedToSubmit.value = true 23 | 24 | val result = shopRepository.insert(screenState.name.value.data.orEmpty()) 25 | 26 | if (result.isError()) { 27 | when (result.error!!) { 28 | InsertResult.InvalidName -> { 29 | screenState.name.apply { 30 | value = value.toError(FieldError.InvalidValueError) 31 | } 32 | } 33 | 34 | InsertResult.DuplicateName -> { 35 | screenState.name.apply { 36 | value = value.toError(FieldError.DuplicateValueError) 37 | } 38 | } 39 | } 40 | } 41 | 42 | return@async result 43 | } 44 | .await() 45 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/modify/shop/editshop/EditShopRoute.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.modify.shop.editshop 2 | 3 | 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.LaunchedEffect 6 | import androidx.compose.runtime.rememberCoroutineScope 7 | import androidx.compose.ui.res.stringResource 8 | import com.kssidll.arru.R 9 | import com.kssidll.arru.ui.screen.modify.shop.ModifyShopScreenImpl 10 | import dev.olshevski.navigation.reimagined.hilt.hiltViewModel 11 | import kotlinx.coroutines.launch 12 | 13 | @Composable 14 | fun EditShopRoute( 15 | shopId: Long, 16 | navigateBack: () -> Unit, 17 | navigateBackDelete: () -> Unit, 18 | ) { 19 | val scope = rememberCoroutineScope() 20 | 21 | val viewModel: EditShopViewModel = hiltViewModel() 22 | 23 | LaunchedEffect(shopId) { 24 | if (!viewModel.updateState(shopId)) { 25 | navigateBack() 26 | } 27 | } 28 | 29 | ModifyShopScreenImpl( 30 | onBack = navigateBack, 31 | state = viewModel.screenState, 32 | onSubmit = { 33 | scope.launch { 34 | if (viewModel.updateShop(shopId) 35 | .isNotError() 36 | ) { 37 | navigateBack() 38 | } 39 | } 40 | }, 41 | onDelete = { 42 | scope.launch { 43 | if (viewModel.deleteShop(shopId) 44 | .isNotError() 45 | ) { 46 | navigateBackDelete() 47 | } 48 | } 49 | }, 50 | onMerge = { 51 | scope.launch { 52 | if (viewModel.mergeWith(it) 53 | .isNotError() 54 | ) { 55 | navigateBackDelete() 56 | } 57 | } 58 | }, 59 | mergeCandidates = viewModel.allMergeCandidates(shopId), 60 | mergeConfirmMessageTemplate = stringResource(id = R.string.merge_action_message_template) 61 | .replace( 62 | "{value_1}", 63 | viewModel.mergeMessageShopName 64 | ), 65 | 66 | chosenMergeCandidate = viewModel.chosenMergeCandidate.value, 67 | onChosenMergeCandidateChange = { 68 | viewModel.chosenMergeCandidate.apply { value = it } 69 | }, 70 | showMergeConfirmDialog = viewModel.showMergeConfirmDialog.value, 71 | onShowMergeConfirmDialogChange = { 72 | viewModel.showMergeConfirmDialog.apply { value = it } 73 | }, 74 | submitButtonText = stringResource(id = R.string.item_shop_edit), 75 | ) 76 | } 77 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/modify/transaction/ModifyTransactionViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.modify.transaction 2 | 3 | import androidx.compose.runtime.MutableState 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.viewModelScope 7 | import com.kssidll.arru.data.data.Shop 8 | import com.kssidll.arru.data.repository.ShopRepositorySource 9 | import com.kssidll.arru.domain.data.Data 10 | import com.kssidll.arru.domain.data.Field 11 | import com.kssidll.arru.ui.screen.modify.ModifyScreenState 12 | import kotlinx.coroutines.Job 13 | import kotlinx.coroutines.flow.Flow 14 | import kotlinx.coroutines.flow.collectLatest 15 | import kotlinx.coroutines.launch 16 | 17 | abstract class ModifyTransactionViewModel: ViewModel() { 18 | protected abstract val shopRepository: ShopRepositorySource 19 | internal val screenState: ModifyTransactionScreenState = ModifyTransactionScreenState() 20 | 21 | private var mShopListener: Job? = null 22 | 23 | /** 24 | * @return List of all shops 25 | */ 26 | fun allShops(): Flow>> { 27 | return shopRepository.allFlow() 28 | } 29 | 30 | suspend fun setSelectedShopToProvided(providedShopId: Long?) { 31 | if (providedShopId != null) { 32 | screenState.selectedShop.apply { value = value.toLoading() } 33 | onNewShopSelected(shopRepository.get(providedShopId)) 34 | } 35 | } 36 | 37 | fun onNewShopSelected(shop: Shop?) { 38 | // Don't do anything if the shop is the same as already selected 39 | if (screenState.selectedShop.value.data == shop) { 40 | screenState.selectedShop.apply { value = value.toLoaded() } 41 | return 42 | } 43 | 44 | screenState.selectedShop.value = Field.Loaded(shop) 45 | 46 | mShopListener?.cancel() 47 | if (shop != null) { 48 | mShopListener = viewModelScope.launch { 49 | shopRepository.getFlow(shop.id) 50 | .collectLatest { 51 | if (it is Data.Loaded) { 52 | screenState.selectedShop.value = Field.Loaded(it.data) 53 | } 54 | } 55 | } 56 | } 57 | } 58 | } 59 | 60 | data class ModifyTransactionScreenState( 61 | val date: MutableState> = mutableStateOf(Field.Loaded()), 62 | val totalCost: MutableState> = mutableStateOf(Field.Loaded()), 63 | val selectedShop: MutableState> = mutableStateOf(Field.Loaded()), 64 | 65 | var isDatePickerDialogExpanded: MutableState = mutableStateOf(false), 66 | var isShopSearchDialogExpanded: MutableState = mutableStateOf(false), 67 | ): ModifyScreenState() { 68 | 69 | /** 70 | * Sets all fields to Loading status 71 | */ 72 | fun allToLoading() { 73 | date.apply { value = value.toLoading() } 74 | totalCost.apply { value = value.toLoading() } 75 | selectedShop.apply { value = value.toLoading() } 76 | } 77 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/modify/transaction/addtransaction/AddTransactionRoute.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.modify.transaction.addtransaction 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.LaunchedEffect 5 | import androidx.compose.runtime.collectAsState 6 | import androidx.compose.runtime.rememberCoroutineScope 7 | import com.kssidll.arru.domain.data.Data 8 | import com.kssidll.arru.ui.screen.modify.transaction.ModifyTransactionScreenImpl 9 | import dev.olshevski.navigation.reimagined.hilt.hiltViewModel 10 | import kotlinx.coroutines.launch 11 | 12 | @Composable 13 | fun AddTransactionRoute( 14 | isExpandedScreen: Boolean, 15 | navigateBack: () -> Unit, 16 | navigateTransaction: (transactionId: Long) -> Unit, 17 | navigateShopAdd: (query: String?) -> Unit, 18 | navigateShopEdit: (shopId: Long) -> Unit, 19 | providedShopId: Long?, 20 | ) { 21 | val scope = rememberCoroutineScope() 22 | val viewModel: AddTransactionViewModel = hiltViewModel() 23 | 24 | LaunchedEffect(providedShopId) { 25 | viewModel.setSelectedShopToProvided(providedShopId) 26 | } 27 | 28 | ModifyTransactionScreenImpl( 29 | isExpandedScreen = isExpandedScreen, 30 | onBack = navigateBack, 31 | state = viewModel.screenState, 32 | shops = viewModel.allShops() 33 | .collectAsState(initial = Data.Loading()).value, 34 | onNewShopSelected = { 35 | viewModel.onNewShopSelected(it) 36 | }, 37 | onSubmit = { 38 | scope.launch { 39 | val result = viewModel.addTransaction() 40 | if (result.isNotError() && result.id != null) { 41 | navigateBack() 42 | navigateTransaction(result.id) 43 | } 44 | } 45 | }, 46 | onShopAddButtonClick = navigateShopAdd, 47 | onTransactionShopLongClick = navigateShopEdit, 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/modify/transaction/edittransaction/EditTransactionRoute.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.modify.transaction.edittransaction 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.LaunchedEffect 5 | import androidx.compose.runtime.collectAsState 6 | import androidx.compose.runtime.rememberCoroutineScope 7 | import androidx.compose.ui.res.stringResource 8 | import com.kssidll.arru.R 9 | import com.kssidll.arru.domain.data.Data 10 | import com.kssidll.arru.ui.screen.modify.transaction.ModifyTransactionScreenImpl 11 | import dev.olshevski.navigation.reimagined.hilt.hiltViewModel 12 | import kotlinx.coroutines.launch 13 | 14 | @Composable 15 | fun EditTransactionRoute( 16 | isExpandedScreen: Boolean, 17 | transactionId: Long, 18 | navigateBack: () -> Unit, 19 | navigateBackDelete: (transactionId: Long) -> Unit, 20 | navigateShopAdd: (query: String?) -> Unit, 21 | navigateShopEdit: (shopId: Long) -> Unit, 22 | providedShopId: Long?, 23 | ) { 24 | val scope = rememberCoroutineScope() 25 | val viewModel: EditTransactionViewModel = hiltViewModel() 26 | 27 | LaunchedEffect(transactionId) { 28 | if (!viewModel.updateState(transactionId)) { 29 | navigateBack() 30 | } 31 | } 32 | 33 | LaunchedEffect(providedShopId) { 34 | viewModel.setSelectedShopToProvided(providedShopId) 35 | } 36 | 37 | ModifyTransactionScreenImpl( 38 | isExpandedScreen = isExpandedScreen, 39 | onBack = navigateBack, 40 | state = viewModel.screenState, 41 | shops = viewModel.allShops() 42 | .collectAsState(initial = Data.Loading()).value, 43 | onNewShopSelected = { 44 | viewModel.onNewShopSelected(it) 45 | }, 46 | onSubmit = { 47 | scope.launch { 48 | if (viewModel.updateTransaction(transactionId) 49 | .isNotError() 50 | ) { 51 | navigateBack() 52 | } 53 | } 54 | }, 55 | onDelete = { 56 | scope.launch { 57 | if (viewModel.deleteTransaction(transactionId) 58 | .isNotError() 59 | ) { 60 | navigateBackDelete(transactionId) 61 | } 62 | } 63 | }, 64 | submitButtonText = stringResource(id = R.string.transaction_edit), 65 | onShopAddButtonClick = navigateShopAdd, 66 | onTransactionShopLongClick = navigateShopEdit, 67 | ) 68 | } 69 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/modify/variant/ModifyVariantViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.modify.variant 2 | 3 | import androidx.compose.runtime.MutableState 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.lifecycle.ViewModel 6 | import com.kssidll.arru.data.repository.VariantRepositorySource 7 | import com.kssidll.arru.domain.data.Field 8 | import com.kssidll.arru.ui.screen.modify.ModifyScreenState 9 | 10 | /** 11 | * Base [ViewModel] class for Variant modification view models 12 | * @property screenState A [ModifyVariantScreenState] instance to use as screen state representation 13 | */ 14 | abstract class ModifyVariantViewModel: ViewModel() { 15 | protected abstract val variantRepository: VariantRepositorySource 16 | internal val screenState: ModifyVariantScreenState = ModifyVariantScreenState() 17 | } 18 | 19 | /** 20 | * Data representing [ModifyVariantScreenImpl] screen state 21 | */ 22 | data class ModifyVariantScreenState( 23 | val name: MutableState> = mutableStateOf(Field.Loaded()), 24 | ): ModifyScreenState() 25 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/modify/variant/addvariant/AddVariantRoute.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.modify.variant.addvariant 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.LaunchedEffect 5 | import androidx.compose.runtime.rememberCoroutineScope 6 | import com.kssidll.arru.domain.data.Field 7 | import com.kssidll.arru.ui.screen.modify.variant.ModifyVariantScreenImpl 8 | import dev.olshevski.navigation.reimagined.hilt.hiltViewModel 9 | import kotlinx.coroutines.launch 10 | 11 | @Composable 12 | fun AddVariantRoute( 13 | productId: Long, 14 | defaultName: String?, 15 | navigateBack: (variantId: Long?) -> Unit, 16 | ) { 17 | val scope = rememberCoroutineScope() 18 | val viewModel: AddVariantViewModel = hiltViewModel() 19 | 20 | LaunchedEffect(Unit) { 21 | viewModel.screenState.name.value = Field.Loaded(defaultName) 22 | } 23 | 24 | ModifyVariantScreenImpl( 25 | onBack = { 26 | navigateBack(null) 27 | }, 28 | state = viewModel.screenState, 29 | onSubmit = { 30 | scope.launch { 31 | val result = viewModel.addVariant(productId) 32 | if (result.isNotError()) { 33 | navigateBack(result.id) 34 | } 35 | } 36 | } 37 | ) 38 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/modify/variant/addvariant/AddVariantViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.modify.variant.addvariant 2 | 3 | import android.util.Log 4 | import androidx.lifecycle.viewModelScope 5 | import com.kssidll.arru.data.repository.VariantRepositorySource 6 | import com.kssidll.arru.data.repository.VariantRepositorySource.Companion.InsertResult 7 | import com.kssidll.arru.domain.data.FieldError 8 | import com.kssidll.arru.ui.screen.modify.variant.ModifyVariantViewModel 9 | import dagger.hilt.android.lifecycle.HiltViewModel 10 | import kotlinx.coroutines.async 11 | import javax.inject.Inject 12 | 13 | @HiltViewModel 14 | class AddVariantViewModel @Inject constructor( 15 | override val variantRepository: VariantRepositorySource, 16 | ): ModifyVariantViewModel() { 17 | 18 | /** 19 | * Tries to add a product variant to the repository 20 | * @param productId: Id of the product that the variant is being created for 21 | * @return resulting [InsertResult] 22 | */ 23 | suspend fun addVariant(productId: Long) = viewModelScope.async { 24 | screenState.attemptedToSubmit.value = true 25 | 26 | val result = variantRepository.insert( 27 | productId, 28 | screenState.name.value.data.orEmpty() 29 | ) 30 | 31 | if (result.isError()) { 32 | when (result.error!!) { 33 | is InsertResult.InvalidName -> { 34 | screenState.name.apply { 35 | value = value.toError(FieldError.InvalidValueError) 36 | } 37 | } 38 | 39 | is InsertResult.DuplicateName -> { 40 | screenState.name.apply { 41 | value = value.toError(FieldError.DuplicateValueError) 42 | } 43 | } 44 | 45 | is InsertResult.InvalidProductId -> { 46 | Log.e( 47 | "InvalidId", 48 | "Tried to insert variant with invalid productId in AddVariantViewModel" 49 | ) 50 | 51 | return@async InsertResult.Success(0) 52 | } 53 | } 54 | } 55 | 56 | return@async result 57 | } 58 | .await() 59 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/modify/variant/editvariant/EditVariantRoute.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.modify.variant.editvariant 2 | 3 | 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.LaunchedEffect 6 | import androidx.compose.runtime.rememberCoroutineScope 7 | import androidx.compose.ui.res.stringResource 8 | import com.kssidll.arru.R 9 | import com.kssidll.arru.ui.screen.modify.variant.ModifyVariantScreenImpl 10 | import dev.olshevski.navigation.reimagined.hilt.hiltViewModel 11 | import kotlinx.coroutines.launch 12 | 13 | @Composable 14 | fun EditVariantRoute( 15 | variantId: Long, 16 | navigateBack: () -> Unit, 17 | navigateBackDelete: () -> Unit, 18 | ) { 19 | val scope = rememberCoroutineScope() 20 | 21 | val viewModel: EditVariantViewModel = hiltViewModel() 22 | 23 | LaunchedEffect(variantId) { 24 | if (!viewModel.updateState(variantId)) { 25 | navigateBack() 26 | } 27 | } 28 | 29 | ModifyVariantScreenImpl( 30 | onBack = navigateBack, 31 | state = viewModel.screenState, 32 | onSubmit = { 33 | scope.launch { 34 | if (viewModel.updateVariant(variantId) 35 | .isNotError() 36 | ) { 37 | navigateBack() 38 | } 39 | } 40 | }, 41 | onDelete = { 42 | scope.launch { 43 | if (viewModel.deleteVariant(variantId) 44 | .isNotError() 45 | ) { 46 | navigateBackDelete() 47 | } 48 | } 49 | }, 50 | submitButtonText = stringResource(id = R.string.item_product_variant_edit), 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/ranking/categoryranking/CategoryRankingRoute.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.ranking.categoryranking 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.collectAsState 5 | import androidx.compose.ui.res.stringResource 6 | import com.kssidll.arru.R 7 | import com.kssidll.arru.ui.screen.ranking.RankingScreen 8 | import dev.olshevski.navigation.reimagined.hilt.hiltViewModel 9 | 10 | @Composable 11 | fun CategoryRankingRoute( 12 | navigateBack: () -> Unit, 13 | navigateCategory: (categoryId: Long) -> Unit, 14 | navigateCategoryEdit: (categoryId: Long) -> Unit, 15 | ) { 16 | val viewModel: CategoryRankingViewModel = hiltViewModel() 17 | 18 | RankingScreen( 19 | onBack = navigateBack, 20 | title = stringResource(R.string.categories), 21 | data = viewModel.categoryTotalSpentFlow() 22 | .collectAsState(emptyList()).value, 23 | onItemClick = { 24 | navigateCategory(it.category.id) 25 | }, 26 | onItemClickLabel = stringResource(id = R.string.select), 27 | onItemLongClick = { 28 | navigateCategoryEdit(it.category.id) 29 | }, 30 | onItemLongClickLabel = stringResource(id = R.string.edit), 31 | ) 32 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/ranking/categoryranking/CategoryRankingViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.ranking.categoryranking 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.kssidll.arru.data.data.ItemSpentByCategory 5 | import com.kssidll.arru.data.repository.CategoryRepositorySource 6 | import dagger.hilt.android.lifecycle.HiltViewModel 7 | import kotlinx.coroutines.flow.Flow 8 | import javax.inject.Inject 9 | 10 | @HiltViewModel 11 | class CategoryRankingViewModel @Inject constructor( 12 | private val categoryRepository: CategoryRepositorySource, 13 | ): ViewModel() { 14 | 15 | /** 16 | * @return List of data points representing shop spending in time as flow 17 | */ 18 | fun categoryTotalSpentFlow(): Flow> { 19 | return categoryRepository.totalSpentByCategoryFlow() 20 | } 21 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/ranking/shopranking/ShopRankingRoute.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.ranking.shopranking 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.collectAsState 5 | import androidx.compose.ui.res.stringResource 6 | import com.kssidll.arru.R 7 | import com.kssidll.arru.ui.screen.ranking.RankingScreen 8 | import dev.olshevski.navigation.reimagined.hilt.hiltViewModel 9 | 10 | @Composable 11 | fun ShopRankingRoute( 12 | navigateBack: () -> Unit, 13 | navigateShop: (shopId: Long) -> Unit, 14 | navigateShopEdit: (shopId: Long) -> Unit, 15 | ) { 16 | val viewModel: ShopRankingViewModel = hiltViewModel() 17 | 18 | RankingScreen( 19 | onBack = navigateBack, 20 | title = stringResource(R.string.shops), 21 | data = viewModel.shopTotalSpentFlow() 22 | .collectAsState(emptyList()).value, 23 | onItemClick = { 24 | navigateShop(it.shop.id) 25 | }, 26 | onItemClickLabel = stringResource(id = R.string.select), 27 | onItemLongClick = { 28 | navigateShopEdit(it.shop.id) 29 | }, 30 | onItemLongClickLabel = stringResource(id = R.string.edit), 31 | ) 32 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/ranking/shopranking/ShopRankingViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.ranking.shopranking 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.kssidll.arru.data.data.TransactionTotalSpentByShop 5 | import com.kssidll.arru.data.repository.ShopRepositorySource 6 | import dagger.hilt.android.lifecycle.HiltViewModel 7 | import kotlinx.coroutines.flow.Flow 8 | import javax.inject.Inject 9 | 10 | @HiltViewModel 11 | class ShopRankingViewModel @Inject constructor( 12 | private val shopRepository: ShopRepositorySource 13 | ): ViewModel() { 14 | 15 | /** 16 | * @return List of data points representing shop spending in time as flow 17 | */ 18 | fun shopTotalSpentFlow(): Flow> { 19 | return shopRepository.totalSpentByShopFlow() 20 | } 21 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/search/SearchRoute.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.search 2 | 3 | 4 | import androidx.compose.runtime.Composable 5 | import dev.olshevski.navigation.reimagined.hilt.hiltViewModel 6 | 7 | @Composable 8 | fun SearchRoute( 9 | navigateBack: () -> Unit, 10 | navigateProduct: (productId: Long) -> Unit, 11 | navigateCategory: (categoryId: Long) -> Unit, 12 | navigateProducer: (producerId: Long) -> Unit, 13 | navigateShop: (shopId: Long) -> Unit, 14 | navigateProductEdit: (productId: Long) -> Unit, 15 | navigateCategoryEdit: (categoryId: Long) -> Unit, 16 | navigateProducerEdit: (producerId: Long) -> Unit, 17 | navigateShopEdit: (shopId: Long) -> Unit, 18 | ) { 19 | val viewModel: SearchViewModel = hiltViewModel() 20 | 21 | SearchScreen( 22 | onBack = navigateBack, 23 | state = viewModel.screenState, 24 | onProductClick = navigateProduct, 25 | onCategoryClick = navigateCategory, 26 | onProducerClick = navigateProducer, 27 | onShopClick = navigateShop, 28 | onProductLongClick = navigateProductEdit, 29 | onCategoryLongClick = navigateCategoryEdit, 30 | onProducerLongClick = navigateProducerEdit, 31 | onShopLongClick = navigateShopEdit, 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/search/SearchViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.search 2 | 3 | 4 | import androidx.lifecycle.ViewModel 5 | import dagger.hilt.android.lifecycle.HiltViewModel 6 | import javax.inject.Inject 7 | 8 | @HiltViewModel 9 | class SearchViewModel @Inject constructor( 10 | 11 | ): ViewModel() { 12 | internal val screenState: SearchScreenState = SearchScreenState() 13 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/search/categorylist/CategoryListRoute.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.search.categorylist 2 | 3 | 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.collectAsState 6 | import com.kssidll.arru.domain.data.Data 7 | import com.kssidll.arru.ui.screen.search.shared.SearchList 8 | import dev.olshevski.navigation.reimagined.hilt.hiltViewModel 9 | 10 | @Composable 11 | fun CategoryListRoute( 12 | onCategoryClick: (categoryId: Long) -> Unit, 13 | onCategoryLongClick: (categoryId: Long) -> Unit, 14 | ) { 15 | val viewModel: CategoryListViewModel = hiltViewModel() 16 | 17 | SearchList( 18 | filter = viewModel.filter, 19 | onFilterChange = { 20 | viewModel.filter = it 21 | }, 22 | items = viewModel.items() 23 | .collectAsState(initial = Data.Loading()).value, 24 | onItemClick = { 25 | onCategoryClick(it.category.id) 26 | }, 27 | onItemLongClick = { 28 | onCategoryLongClick(it.category.id) 29 | }, 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/search/categorylist/CategoryListViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.search.categorylist 2 | 3 | 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.mutableStateOf 6 | import androidx.compose.runtime.setValue 7 | import androidx.lifecycle.ViewModel 8 | import com.kssidll.arru.data.repository.CategoryRepositorySource 9 | import dagger.hilt.android.lifecycle.HiltViewModel 10 | import javax.inject.Inject 11 | 12 | @HiltViewModel 13 | class CategoryListViewModel @Inject constructor( 14 | private val categoryRepository: CategoryRepositorySource, 15 | ): ViewModel() { 16 | private val _filter = mutableStateOf(String()) 17 | var filter by _filter 18 | 19 | fun items() = categoryRepository.allWithAltNamesFlow() 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/search/component/SearchItem.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.search.component 2 | 3 | 4 | import androidx.compose.foundation.ExperimentalFoundationApi 5 | import androidx.compose.foundation.clickable 6 | import androidx.compose.foundation.combinedClickable 7 | import androidx.compose.foundation.layout.Box 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.height 10 | import androidx.compose.material3.Surface 11 | import androidx.compose.material3.Text 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.Alignment 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.res.stringResource 16 | import androidx.compose.ui.semantics.Role 17 | import androidx.compose.ui.text.style.TextAlign 18 | import androidx.compose.ui.tooling.preview.PreviewLightDark 19 | import androidx.compose.ui.unit.Dp 20 | import androidx.compose.ui.unit.dp 21 | import com.kssidll.arru.R 22 | import com.kssidll.arru.ui.theme.ArrugarqTheme 23 | import com.kssidll.arru.ui.theme.Typography 24 | 25 | private val DefaultItemHeight: Dp = 80.dp 26 | 27 | @OptIn(ExperimentalFoundationApi::class) 28 | @Composable 29 | internal fun SearchItem( 30 | text: String, 31 | onItemClick: () -> Unit, 32 | onItemLongClick: (() -> Unit)? = null, 33 | itemHeight: Dp = DefaultItemHeight, 34 | ) { 35 | val containerModifier = 36 | if (onItemLongClick == null) 37 | Modifier.clickable( 38 | role = Role.Button, 39 | onClickLabel = stringResource(id = R.string.select) 40 | ) { 41 | onItemClick() 42 | } 43 | else Modifier 44 | .combinedClickable( 45 | role = Role.Button, 46 | onClick = { 47 | onItemClick() 48 | }, 49 | onClickLabel = stringResource(id = R.string.select), 50 | onLongClick = { 51 | onItemLongClick() 52 | }, 53 | onLongClickLabel = stringResource(id = R.string.edit) 54 | ) 55 | 56 | Box( 57 | modifier = containerModifier 58 | .fillMaxWidth() 59 | .height(itemHeight) 60 | ) { 61 | Text( 62 | text = text, 63 | style = Typography.titleLarge, 64 | textAlign = TextAlign.Center, 65 | modifier = Modifier.align(Alignment.Center) 66 | ) 67 | } 68 | } 69 | 70 | @PreviewLightDark 71 | @Composable 72 | private fun SearchItemPreview() { 73 | ArrugarqTheme { 74 | Surface { 75 | SearchItem( 76 | text = "test", 77 | onItemClick = {}, 78 | onItemLongClick = {}, 79 | ) 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/search/producerlist/ProducerListRoute.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.search.producerlist 2 | 3 | 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.collectAsState 6 | import com.kssidll.arru.domain.data.Data 7 | import com.kssidll.arru.ui.screen.search.shared.SearchList 8 | import dev.olshevski.navigation.reimagined.hilt.hiltViewModel 9 | 10 | @Composable 11 | fun ProducerListRoute( 12 | onProducerClick: (producerId: Long) -> Unit, 13 | onProducerLongClick: (producerId: Long) -> Unit, 14 | ) { 15 | val viewModel: ProducerListViewModel = hiltViewModel() 16 | 17 | SearchList( 18 | filter = viewModel.filter, 19 | onFilterChange = { 20 | viewModel.filter = it 21 | }, 22 | items = viewModel.items() 23 | .collectAsState(initial = Data.Loading()).value, 24 | onItemClick = { 25 | onProducerClick(it.id) 26 | }, 27 | onItemLongClick = { 28 | onProducerLongClick(it.id) 29 | }, 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/search/producerlist/ProducerListViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.search.producerlist 2 | 3 | 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.mutableStateOf 6 | import androidx.compose.runtime.setValue 7 | import androidx.lifecycle.ViewModel 8 | import com.kssidll.arru.data.repository.ProducerRepositorySource 9 | import dagger.hilt.android.lifecycle.HiltViewModel 10 | import javax.inject.Inject 11 | 12 | @HiltViewModel 13 | class ProducerListViewModel @Inject constructor( 14 | private val producerRepository: ProducerRepositorySource, 15 | ): ViewModel() { 16 | private val _filter = mutableStateOf(String()) 17 | var filter by _filter 18 | 19 | fun items() = producerRepository.allFlow() 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/search/productlist/ProductListRoute.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.search.productlist 2 | 3 | 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.collectAsState 6 | import com.kssidll.arru.domain.data.Data 7 | import com.kssidll.arru.ui.screen.search.shared.SearchList 8 | import dev.olshevski.navigation.reimagined.hilt.hiltViewModel 9 | 10 | @Composable 11 | internal fun ProductListRoute( 12 | onProductClick: (productId: Long) -> Unit, 13 | onProductLongClick: (productId: Long) -> Unit, 14 | ) { 15 | val viewModel: ProductListViewModel = hiltViewModel() 16 | 17 | SearchList( 18 | filter = viewModel.filter, 19 | onFilterChange = { 20 | viewModel.filter = it 21 | }, 22 | items = viewModel.items() 23 | .collectAsState(initial = Data.Loading()).value, 24 | onItemClick = { 25 | onProductClick(it.product.id) 26 | }, 27 | onItemLongClick = { 28 | onProductLongClick(it.product.id) 29 | }, 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/search/productlist/ProductListViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.search.productlist 2 | 3 | 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.mutableStateOf 6 | import androidx.compose.runtime.setValue 7 | import androidx.lifecycle.ViewModel 8 | import com.kssidll.arru.data.repository.ProductRepositorySource 9 | import dagger.hilt.android.lifecycle.HiltViewModel 10 | import javax.inject.Inject 11 | 12 | @HiltViewModel 13 | class ProductListViewModel @Inject constructor( 14 | private val productRepository: ProductRepositorySource, 15 | ): ViewModel() { 16 | private val _filter = mutableStateOf(String()) 17 | var filter by _filter 18 | 19 | fun items() = productRepository.allWithAltNamesFlow() 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/search/shoplist/ShopListRoute.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.search.shoplist 2 | 3 | 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.collectAsState 6 | import com.kssidll.arru.domain.data.Data 7 | import com.kssidll.arru.ui.screen.search.shared.SearchList 8 | import dev.olshevski.navigation.reimagined.hilt.hiltViewModel 9 | 10 | @Composable 11 | internal fun ShopListRoute( 12 | onShopClick: (shopId: Long) -> Unit, 13 | onShopLongClick: (shopId: Long) -> Unit, 14 | ) { 15 | val viewModel: ShopListViewModel = hiltViewModel() 16 | 17 | SearchList( 18 | filter = viewModel.filter, 19 | onFilterChange = { 20 | viewModel.filter = it 21 | }, 22 | items = viewModel.items() 23 | .collectAsState(initial = Data.Loading()).value, 24 | onItemClick = { 25 | onShopClick(it.id) 26 | }, 27 | onItemLongClick = { 28 | onShopLongClick(it.id) 29 | }, 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/search/shoplist/ShopListViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.search.shoplist 2 | 3 | 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.mutableStateOf 6 | import androidx.compose.runtime.setValue 7 | import androidx.lifecycle.ViewModel 8 | import com.kssidll.arru.data.repository.ShopRepositorySource 9 | import dagger.hilt.android.lifecycle.HiltViewModel 10 | import javax.inject.Inject 11 | 12 | @HiltViewModel 13 | class ShopListViewModel @Inject constructor( 14 | private val shopRepository: ShopRepositorySource, 15 | ): ViewModel() { 16 | private val _filter = mutableStateOf(String()) 17 | var filter by _filter 18 | 19 | fun items() = shopRepository.allFlow() 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/search/start/StartRoute.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.search.start 2 | 3 | 4 | import androidx.compose.runtime.Composable 5 | 6 | @Composable 7 | internal fun StartRoute( 8 | onProductClick: () -> Unit, 9 | onCategoryClick: () -> Unit, 10 | onShopClick: () -> Unit, 11 | onProducerClick: () -> Unit, 12 | ) { 13 | StartScreen( 14 | onProductClick = onProductClick, 15 | onCategoryClick = onCategoryClick, 16 | onShopClick = onShopClick, 17 | onProducerClick = onProducerClick, 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/spendingcomparison/categoryspendingcomparison/CategorySpendingComparisonRoute.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.spendingcomparison.categoryspendingcomparison 2 | 3 | 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.collectAsState 6 | import androidx.compose.ui.res.stringResource 7 | import com.kssidll.arru.R 8 | import com.kssidll.arru.ui.screen.spendingcomparison.SpendingComparisonScreen 9 | import dev.olshevski.navigation.reimagined.hilt.hiltViewModel 10 | import java.text.SimpleDateFormat 11 | import java.util.Calendar 12 | import java.util.Locale 13 | 14 | @Composable 15 | fun CategorySpendingComparisonRoute( 16 | navigateBack: () -> Unit, 17 | year: Int, 18 | month: Int, 19 | ) { 20 | val viewModel: CategorySpendingComparisonViewModel = hiltViewModel() 21 | 22 | val calendar = Calendar.getInstance() 23 | calendar.clear() 24 | calendar.set( 25 | Calendar.MONTH, 26 | month - 1 27 | ) // calendar has 0 - 11 month indexes 28 | 29 | val formatter = SimpleDateFormat( 30 | "LLLL", 31 | Locale.getDefault() 32 | ) 33 | 34 | SpendingComparisonScreen( 35 | onBack = navigateBack, 36 | title = "${ 37 | formatter.format(calendar.time) 38 | .replaceFirstChar { it.titlecase() } 39 | } $year", 40 | leftSideItems = viewModel.categoryTotalSpentPreviousMonth( 41 | year, 42 | month 43 | ) 44 | .collectAsState(initial = emptyList()).value, 45 | leftSideHeader = stringResource(id = R.string.previous), 46 | rightSideItems = viewModel.categoryTotalSpentCurrentMonth( 47 | year, 48 | month 49 | ) 50 | .collectAsState(initial = emptyList()).value, 51 | rightSideHeader = stringResource(id = R.string.current), 52 | ) 53 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/spendingcomparison/categoryspendingcomparison/CategorySpendingComparisonViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.spendingcomparison.categoryspendingcomparison 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.kssidll.arru.data.data.ItemSpentByCategory 5 | import com.kssidll.arru.data.repository.CategoryRepositorySource 6 | import dagger.hilt.android.lifecycle.HiltViewModel 7 | import kotlinx.coroutines.flow.Flow 8 | import javax.inject.Inject 9 | 10 | @HiltViewModel 11 | class CategorySpendingComparisonViewModel @Inject constructor( 12 | private val categoryRepository: CategoryRepositorySource 13 | ): ViewModel() { 14 | 15 | /** 16 | * @return List of data points representing category spending in [year] and [month] 17 | */ 18 | fun categoryTotalSpentCurrentMonth( 19 | year: Int, 20 | month: Int 21 | ): Flow> { 22 | return categoryRepository.totalSpentByCategoryByMonthFlow( 23 | year, 24 | month 25 | ) 26 | } 27 | 28 | /** 29 | * @return List of data points representing category spending in previous month for [year] and [month] 30 | */ 31 | fun categoryTotalSpentPreviousMonth( 32 | year: Int, 33 | month: Int 34 | ): Flow> { 35 | var localYear: Int = year 36 | var localMonth: Int = month 37 | 38 | if (localMonth == 1) { 39 | localYear -= 1 40 | localMonth = 12 41 | } else { 42 | localMonth -= 1 43 | } 44 | 45 | return categoryRepository.totalSpentByCategoryByMonthFlow( 46 | localYear, 47 | localMonth 48 | ) 49 | } 50 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/spendingcomparison/shopspendingcomparison/ShopSpendingComparisonRoute.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.spendingcomparison.shopspendingcomparison 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.collectAsState 5 | import androidx.compose.ui.res.stringResource 6 | import com.kssidll.arru.R 7 | import com.kssidll.arru.ui.screen.spendingcomparison.SpendingComparisonScreen 8 | import dev.olshevski.navigation.reimagined.hilt.hiltViewModel 9 | import java.text.SimpleDateFormat 10 | import java.util.Calendar 11 | import java.util.Locale 12 | 13 | @Composable 14 | fun ShopSpendingComparisonRoute( 15 | navigateBack: () -> Unit, 16 | year: Int, 17 | month: Int, 18 | ) { 19 | val viewModel: ShopSpendingComparisonViewModel = hiltViewModel() 20 | 21 | val calendar = Calendar.getInstance() 22 | calendar.clear() 23 | calendar.set( 24 | Calendar.MONTH, 25 | month - 1 26 | ) // calendar has 0 - 11 month indexes 27 | 28 | val formatter = SimpleDateFormat( 29 | "LLLL", 30 | Locale.getDefault() 31 | ) 32 | 33 | SpendingComparisonScreen( 34 | onBack = navigateBack, 35 | title = "${ 36 | formatter.format(calendar.time) 37 | .replaceFirstChar { it.titlecase() } 38 | } $year", 39 | leftSideItems = viewModel.shopTotalSpentPreviousMonth( 40 | year, 41 | month 42 | ) 43 | .collectAsState(initial = emptyList()).value, 44 | leftSideHeader = stringResource(id = R.string.previous), 45 | rightSideItems = viewModel.shopTotalSpentCurrentMonth( 46 | year, 47 | month 48 | ) 49 | .collectAsState(initial = emptyList()).value, 50 | rightSideHeader = stringResource(id = R.string.current), 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/screen/spendingcomparison/shopspendingcomparison/ShopSpendingComparisonViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.screen.spendingcomparison.shopspendingcomparison 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.kssidll.arru.data.data.TransactionTotalSpentByShop 5 | import com.kssidll.arru.data.repository.ShopRepositorySource 6 | import dagger.hilt.android.lifecycle.HiltViewModel 7 | import kotlinx.coroutines.flow.Flow 8 | import javax.inject.Inject 9 | 10 | @HiltViewModel 11 | class ShopSpendingComparisonViewModel @Inject constructor( 12 | private val shopRepository: ShopRepositorySource 13 | ): ViewModel() { 14 | 15 | /** 16 | * @return List of data points representing category spending in [year] and [month] 17 | */ 18 | fun shopTotalSpentCurrentMonth( 19 | year: Int, 20 | month: Int 21 | ): Flow> { 22 | return shopRepository.totalSpentByShopByMonthFlow( 23 | year, 24 | month 25 | ) 26 | } 27 | 28 | /** 29 | * @return List of data points representing category spending in previous month for [year] and [month] 30 | */ 31 | fun shopTotalSpentPreviousMonth( 32 | year: Int, 33 | month: Int 34 | ): Flow> { 35 | var localYear: Int = year 36 | var localMonth: Int = month 37 | 38 | if (localMonth == 1) { 39 | localYear -= 1 40 | localMonth = 12 41 | } else { 42 | localMonth -= 1 43 | } 44 | 45 | return shopRepository.totalSpentByShopByMonthFlow( 46 | localYear, 47 | localMonth 48 | ) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | bodyLarge = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp, 15 | lineHeight = 24.sp, 16 | letterSpacing = 0.5.sp 17 | ) 18 | /* Other default text styles to override 19 | titleLarge = TextStyle( 20 | fontFamily = FontFamily.Default, 21 | fontWeight = FontWeight.Normal, 22 | fontSize = 22.sp, 23 | lineHeight = 28.sp, 24 | letterSpacing = 0.sp 25 | ), 26 | labelSmall = TextStyle( 27 | fontFamily = FontFamily.Default, 28 | fontWeight = FontWeight.Medium, 29 | fontSize = 11.sp, 30 | lineHeight = 16.sp, 31 | letterSpacing = 0.5.sp 32 | ) 33 | */ 34 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/theme/schema/Dark.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.theme.schema 2 | 3 | import androidx.compose.material3.darkColorScheme 4 | import androidx.compose.ui.graphics.Color 5 | 6 | /** 7 | * Default application dark color scheme 8 | */ 9 | val DarkColorScheme = darkColorScheme( 10 | primary = Color(0xFFDABAFA), 11 | onPrimary = Color(0xFF3D2459), 12 | primaryContainer = Color(0xFF553B71), 13 | onPrimaryContainer = Color(0xFFEFDBFF), 14 | secondary = Color(0xFFD0C1DA), 15 | onSecondary = Color(0xFF362D3F), 16 | secondaryContainer = Color(0xFF4D4357), 17 | onSecondaryContainer = Color(0xFFECDDF6), 18 | tertiary = Color(0xFFF3B7BF), 19 | onTertiary = Color(0xFF4B252B), 20 | tertiaryContainer = Color(0xFF653A41), 21 | onTertiaryContainer = Color(0xFFFFD9DD), 22 | error = Color(0xFFFFB4AB), 23 | onError = Color(0xFF690005), 24 | errorContainer = Color(0xFF93000A), 25 | onErrorContainer = Color(0xFFFFDAD6), 26 | background = Color(0xFF151218), 27 | onBackground = Color(0xFFE8E0E8), 28 | surface = Color(0xFF151218), 29 | onSurface = Color(0xFFE8E0E8), 30 | surfaceVariant = Color(0xFF4A454E), 31 | onSurfaceVariant = Color(0xFFCCC4CF), 32 | outline = Color(0xFF958E98), 33 | outlineVariant = Color(0xFF4A454E), 34 | scrim = Color(0xFF000000), 35 | inverseSurface = Color(0xFFE8E0E8), 36 | inverseOnSurface = Color(0xFF332F35), 37 | inversePrimary = Color(0xFF6E528B), 38 | surfaceDim = Color(0xFF151218), 39 | surfaceBright = Color(0xFF3C383E), 40 | surfaceContainerLowest = Color(0xFF100D12), 41 | surfaceContainerLow = Color(0xFF1E1A20), 42 | surfaceContainer = Color(0xFF221E24), 43 | surfaceContainerHigh = Color(0xFF2C292E), 44 | surfaceContainerHighest = Color(0xFF373339) 45 | ) 46 | -------------------------------------------------------------------------------- /app/src/main/java/com/kssidll/arru/ui/theme/schema/Light.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.arru.ui.theme.schema 2 | 3 | import androidx.compose.material3.lightColorScheme 4 | import androidx.compose.ui.graphics.Color 5 | 6 | /** 7 | * Default application light color scheme 8 | */ 9 | val LightColorScheme = lightColorScheme( 10 | primary = Color(0xFF6E528B), 11 | onPrimary = Color(0xFFFFFFFF), 12 | primaryContainer = Color(0xFFEFDBFF), 13 | onPrimaryContainer = Color(0xFF270D43), 14 | secondary = Color(0xFF655A6F), 15 | onSecondary = Color(0xFFFFFFFF), 16 | secondaryContainer = Color(0xFFECDDF6), 17 | onSecondaryContainer = Color(0xFF21182A), 18 | tertiary = Color(0xFF805158), 19 | onTertiary = Color(0xFFFFFFFF), 20 | tertiaryContainer = Color(0xFFFFD9DD), 21 | onTertiaryContainer = Color(0xFF321017), 22 | error = Color(0xFFBA1A1A), 23 | onError = Color(0xFFFFFFFF), 24 | errorContainer = Color(0xFFFFDAD6), 25 | onErrorContainer = Color(0xFF410002), 26 | background = Color(0xFFEEE6EE), 27 | onBackground = Color(0xFF1E1A20), 28 | surface = Color(0xFFEEE6EE), 29 | onSurface = Color(0xFF1E1A20), 30 | surfaceVariant = Color(0xFFE9E0EB), 31 | onSurfaceVariant = Color(0xFF4A454E), 32 | outline = Color(0xFF7B757E), 33 | outlineVariant = Color(0xFFCCC4CF), 34 | scrim = Color(0xFF000000), 35 | inverseSurface = Color(0xFF332F35), 36 | inverseOnSurface = Color(0xFFF6EEF6), 37 | inversePrimary = Color(0xFFDABAFA), 38 | surfaceDim = Color(0xFFDFD8DF), 39 | surfaceBright = Color(0xFFFFF7FF), 40 | surfaceContainerLowest = Color(0xFFFFFFFF), 41 | surfaceContainerLow = Color(0xFFF9F1F9), 42 | surfaceContainer = Color(0xFFEFE6FA), 43 | surfaceContainerHigh = Color(0xFFEEE6EE), 44 | surfaceContainerHighest = Color(0xFFE8E0E8) 45 | ) 46 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/close.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 8 | 14 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_monochrome.xml: -------------------------------------------------------------------------------- 1 | 8 | 14 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/resources.properties: -------------------------------------------------------------------------------- 1 | unqualifiedResLocale=en-US -------------------------------------------------------------------------------- /app/src/main/res/values-notnight-v29/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #260F3D 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/xml/backup_rules.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/xml/data_extraction_rules.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 12 | 13 | 19 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | plugins { 3 | alias(libs.plugins.android.application) apply false 4 | alias(libs.plugins.kotlin.android) apply false 5 | alias(libs.plugins.compose) apply false 6 | alias(libs.plugins.hilt) apply false 7 | alias(libs.plugins.ksp) apply false 8 | alias(libs.plugins.jvm) apply false 9 | } 10 | 11 | tasks.register("clean", Delete::class) { 12 | delete(rootProject.layout.buildDirectory) 13 | } 14 | -------------------------------------------------------------------------------- /crowdin.yml: -------------------------------------------------------------------------------- 1 | bundles: 2 | - 1 3 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/37.txt: -------------------------------------------------------------------------------- 1 | - Fixed transaction screen not taking up the full available space 2 | - Improved transaction date auto selection logic -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/38.txt: -------------------------------------------------------------------------------- 1 | - Add data export 2 | - Add backup locking to prevent accidental deletions 3 | - Fix crash when attempting to delete a variant used in an existing item -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/39.txt: -------------------------------------------------------------------------------- 1 | - Add Turkish locale support -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/40.txt: -------------------------------------------------------------------------------- 1 | - Add in-app theme picking 2 | - Improve theme compatibility on older devices -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/41.txt: -------------------------------------------------------------------------------- 1 | - Add currency locale selection to settings menu 2 | - Add option to change database location (Supported on Android 11 and newer) 3 | - Fix transactions screen state loss on screen change -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/42.txt: -------------------------------------------------------------------------------- 1 | - Fix settings screen text locale 2 | - Fix outdated currency locale data -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/43.txt: -------------------------------------------------------------------------------- 1 | - Add warning when trying to change database location to Downloads as it is extremely dangerous -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/44.txt: -------------------------------------------------------------------------------- 1 | - Implement data import for Arru export formats 2 | - Fix export without notification permission -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/full_description.txt: -------------------------------------------------------------------------------- 1 |

Arru is an app to help you to track and analyze your expenses.


Features:

  • Light/Dark mode
  • Wide screen support
  • Local backups
  • Data export
  • Transaction baskets tracking your total expenditure with optional product, category, shop and producer spending tracking
  • Comparisons between prices at different shops
  • Ranking of categories and shops based on total money spent
  • Merging capabilities for categories, shops, products and producers
-------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/fastlane/metadata/android/en-US/images/icon.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/01-dashboard.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/fastlane/metadata/android/en-US/images/phoneScreenshots/01-dashboard.jpg -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/02-analysis.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/fastlane/metadata/android/en-US/images/phoneScreenshots/02-analysis.jpg -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/03-transactions.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/fastlane/metadata/android/en-US/images/phoneScreenshots/03-transactions.jpg -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/04-product_top.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/fastlane/metadata/android/en-US/images/phoneScreenshots/04-product_top.jpg -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/05-categories_ranking.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/fastlane/metadata/android/en-US/images/phoneScreenshots/05-categories_ranking.jpg -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/06-merge.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/fastlane/metadata/android/en-US/images/phoneScreenshots/06-merge.jpg -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/07-backups.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/fastlane/metadata/android/en-US/images/phoneScreenshots/07-backups.jpg -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/08-transaction_add_item.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/fastlane/metadata/android/en-US/images/phoneScreenshots/08-transaction_add_item.jpg -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/09-transaction_add_select.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/fastlane/metadata/android/en-US/images/phoneScreenshots/09-transaction_add_select.jpg -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/10-transaction_add.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/fastlane/metadata/android/en-US/images/phoneScreenshots/10-transaction_add.jpg -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/short_description.txt: -------------------------------------------------------------------------------- 1 | Track and analyze your expenses -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app's APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Kotlin code style for this project: "official" or "obsolete": 19 | kotlin.code.style=official 20 | # Enables namespacing of each library's R class so that its R class includes only the 21 | # resources declared in the library itself and none from the library's dependencies, 22 | # thereby reducing the size of the R class for that library 23 | android.nonTransitiveRClass=true -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Tue Aug 13 17:07:29 CEST 2024 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /images/analysis.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/images/analysis.jpg -------------------------------------------------------------------------------- /images/backups.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/images/backups.jpg -------------------------------------------------------------------------------- /images/categories_ranking.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/images/categories_ranking.jpg -------------------------------------------------------------------------------- /images/dashboard.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/images/dashboard.jpg -------------------------------------------------------------------------------- /images/merge.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/images/merge.jpg -------------------------------------------------------------------------------- /images/product_top.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/images/product_top.jpg -------------------------------------------------------------------------------- /images/transaction_add.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/images/transaction_add.jpg -------------------------------------------------------------------------------- /images/transaction_add_item.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/images/transaction_add_item.jpg -------------------------------------------------------------------------------- /images/transaction_add_select.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/images/transaction_add_select.jpg -------------------------------------------------------------------------------- /images/transactions.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KSSidll/Arru/cefef347dd6bbca0d701f380bf903474633f329b/images/transactions.jpg -------------------------------------------------------------------------------- /processor/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /processor/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.jvm) 3 | } 4 | 5 | dependencies { 6 | implementation(libs.google.ksp.spa) 7 | } -------------------------------------------------------------------------------- /processor/src/main/java/com/kssidll/processor/CurrencyLocale.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.processor 2 | 3 | import com.google.devtools.ksp.processing.CodeGenerator 4 | import com.google.devtools.ksp.processing.Dependencies 5 | import com.google.devtools.ksp.processing.KSPLogger 6 | import com.google.devtools.ksp.processing.Resolver 7 | import com.google.devtools.ksp.processing.SymbolProcessor 8 | import com.google.devtools.ksp.symbol.KSAnnotated 9 | import java.text.NumberFormat 10 | import java.util.Locale 11 | 12 | class CurrencyLocaleDataGenerator( 13 | private val codeGenerator: CodeGenerator, 14 | private val logger: KSPLogger 15 | ) : SymbolProcessor { 16 | 17 | override fun process(resolver: Resolver): List { 18 | val fileName = "CurrencyLocaleData" 19 | val packageName = "com.kssidll.compiled" 20 | val fileContent = """ 21 | package $packageName 22 | 23 | object $fileName { 24 | val items = listOf( 25 | ${generateCachedItems()} 26 | ) 27 | } 28 | """.trimIndent() 29 | 30 | try { 31 | codeGenerator.createNewFile( 32 | dependencies = Dependencies(false), 33 | packageName = packageName, 34 | fileName = fileName, 35 | extensionName = "kt" 36 | ).use { outputStream -> 37 | outputStream.write(fileContent.toByteArray()) 38 | } 39 | } catch (e: FileAlreadyExistsException) { 40 | logger.info("File $fileName.kt already exists. Skipping generation.") 41 | } 42 | 43 | return emptyList() 44 | } 45 | 46 | private fun generateCachedItems(): String { 47 | return NumberFormat.getAvailableLocales().map { it.toLanguageTag() } 48 | .groupBy { 1.0f.formatToCurrency(Locale.forLanguageTag(it)) } 49 | .toSortedMap { a, b -> b.compareTo(a) } 50 | .map { (currency, tags) -> 51 | "Pair(\"$currency\", listOf(${tags.joinToString { "\"$it\"" }}))" 52 | } 53 | .joinToString(",\n") 54 | } 55 | 56 | private fun Float.formatToCurrency(locale: Locale): String { 57 | return NumberFormat.getCurrencyInstance(locale).format(this) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /processor/src/main/java/com/kssidll/processor/CurrencyLocaleProvider.kt: -------------------------------------------------------------------------------- 1 | package com.kssidll.processor 2 | 3 | import com.google.devtools.ksp.processing.SymbolProcessor 4 | import com.google.devtools.ksp.processing.SymbolProcessorEnvironment 5 | import com.google.devtools.ksp.processing.SymbolProcessorProvider 6 | 7 | class LocaleDataGeneratorProvider : SymbolProcessorProvider { 8 | override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { 9 | return CurrencyLocaleDataGenerator( 10 | codeGenerator = environment.codeGenerator, 11 | logger = environment.logger 12 | ) 13 | } 14 | } 15 | 16 | -------------------------------------------------------------------------------- /processor/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider: -------------------------------------------------------------------------------- 1 | com.kssidll.processor.LocaleDataGeneratorProvider -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnstableApiUsage") 2 | 3 | pluginManagement { 4 | repositories { 5 | gradlePluginPortal() 6 | google() 7 | mavenCentral() 8 | } 9 | } 10 | 11 | dependencyResolutionManagement { 12 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 13 | repositories { 14 | google() 15 | mavenCentral() 16 | } 17 | } 18 | 19 | rootProject.name = "Arru" 20 | include(":app") 21 | include(":processor") --------------------------------------------------------------------------------