├── .github └── workflows │ ├── check.yml │ └── sign.yml ├── .gitignore ├── .run └── Run tests.run.xml ├── LICENSE ├── PRIVACY_POLICY.md ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ ├── androidTest │ ├── AndroidManifest.xml │ └── kotlin │ │ └── gq │ │ └── kirmanak │ │ └── mealient │ │ ├── BaseTestCase.kt │ │ ├── FirstSetUpTest.kt │ │ ├── MealientTestRunner.kt │ │ ├── MockResponse.kt │ │ └── screen │ │ ├── AuthenticationScreen.kt │ │ ├── BaseUrlScreen.kt │ │ ├── DisclaimerScreen.kt │ │ ├── Extensions.kt │ │ └── RecipesListScreen.kt │ ├── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── java │ │ └── gq │ │ │ └── kirmanak │ │ │ └── mealient │ │ │ ├── App.kt │ │ │ ├── data │ │ │ ├── add │ │ │ │ ├── AddRecipeDataSource.kt │ │ │ │ ├── AddRecipeRepo.kt │ │ │ │ └── impl │ │ │ │ │ └── AddRecipeRepoImpl.kt │ │ │ ├── auth │ │ │ │ ├── AuthDataSource.kt │ │ │ │ ├── AuthRepo.kt │ │ │ │ ├── AuthStorage.kt │ │ │ │ └── impl │ │ │ │ │ ├── AuthDataSourceImpl.kt │ │ │ │ │ ├── AuthRepoImpl.kt │ │ │ │ │ ├── AuthStorageImpl.kt │ │ │ │ │ └── CredentialsLogRedactor.kt │ │ │ ├── baseurl │ │ │ │ ├── ServerInfoRepo.kt │ │ │ │ ├── ServerInfoRepoImpl.kt │ │ │ │ ├── ServerInfoStorage.kt │ │ │ │ ├── VersionDataSource.kt │ │ │ │ ├── VersionDataSourceImpl.kt │ │ │ │ └── impl │ │ │ │ │ ├── BaseUrlLogRedactor.kt │ │ │ │ │ └── ServerInfoStorageImpl.kt │ │ │ ├── configuration │ │ │ │ └── BuildConfigurationImpl.kt │ │ │ ├── disclaimer │ │ │ │ ├── DisclaimerStorage.kt │ │ │ │ └── DisclaimerStorageImpl.kt │ │ │ ├── migration │ │ │ │ ├── From24AuthMigrationExecutor.kt │ │ │ │ ├── From30MigrationExecutor.kt │ │ │ │ ├── MigrationDetector.kt │ │ │ │ ├── MigrationDetectorImpl.kt │ │ │ │ └── MigrationExecutor.kt │ │ │ ├── network │ │ │ │ └── MealieDataSourceWrapper.kt │ │ │ ├── recipes │ │ │ │ ├── RecipeRepo.kt │ │ │ │ ├── impl │ │ │ │ │ ├── RecipeImageUrlProvider.kt │ │ │ │ │ ├── RecipeImageUrlProviderImpl.kt │ │ │ │ │ ├── RecipePagingSourceFactory.kt │ │ │ │ │ ├── RecipePagingSourceFactoryImpl.kt │ │ │ │ │ ├── RecipeRepoImpl.kt │ │ │ │ │ └── RecipesRemoteMediator.kt │ │ │ │ └── network │ │ │ │ │ └── RecipeDataSource.kt │ │ │ ├── share │ │ │ │ ├── ParseRecipeDataSource.kt │ │ │ │ ├── ShareRecipeRepo.kt │ │ │ │ └── ShareRecipeRepoImpl.kt │ │ │ └── storage │ │ │ │ ├── PreferencesStorage.kt │ │ │ │ └── PreferencesStorageImpl.kt │ │ │ ├── di │ │ │ ├── AddRecipeModule.kt │ │ │ ├── AppModule.kt │ │ │ ├── ArchitectureModule.kt │ │ │ ├── AuthModule.kt │ │ │ ├── BaseURLModule.kt │ │ │ ├── DisclaimerModule.kt │ │ │ ├── MigrationModule.kt │ │ │ ├── RecipeModule.kt │ │ │ └── ShareRecipeModule.kt │ │ │ ├── extensions │ │ │ ├── ContextExtensions.kt │ │ │ └── ViewExtensions.kt │ │ │ └── ui │ │ │ ├── NavGraphs.kt │ │ │ ├── activity │ │ │ ├── DrawerContent.kt │ │ │ ├── MainActivity.kt │ │ │ ├── MainActivityViewModel.kt │ │ │ └── MealientApp.kt │ │ │ ├── add │ │ │ ├── AddRecipeScreen.kt │ │ │ ├── AddRecipeScreenEvent.kt │ │ │ ├── AddRecipeScreenState.kt │ │ │ ├── AddRecipeSnackbarMessage.kt │ │ │ └── AddRecipeViewModel.kt │ │ │ ├── auth │ │ │ ├── AuthenticationScreen.kt │ │ │ ├── AuthenticationScreenEvent.kt │ │ │ ├── AuthenticationScreenState.kt │ │ │ ├── AuthenticationViewModel.kt │ │ │ ├── Extensions.kt │ │ │ └── PasswordInput.kt │ │ │ ├── baseurl │ │ │ ├── BaseURLScreen.kt │ │ │ ├── BaseURLScreenEvent.kt │ │ │ ├── BaseURLScreenState.kt │ │ │ ├── BaseURLViewModel.kt │ │ │ └── InvalidCertificateDialog.kt │ │ │ ├── disclaimer │ │ │ ├── DisclaimerScreen.kt │ │ │ ├── DisclaimerScreenState.kt │ │ │ └── DisclaimerViewModel.kt │ │ │ ├── recipes │ │ │ ├── info │ │ │ │ ├── HeaderSection.kt │ │ │ │ ├── IngredientsSection.kt │ │ │ │ ├── InstructionsSection.kt │ │ │ │ ├── KeepScreenOn.kt │ │ │ │ ├── PreviewData.kt │ │ │ │ ├── RecipeInfoUiState.kt │ │ │ │ ├── RecipeInfoViewModel.kt │ │ │ │ └── RecipeScreen.kt │ │ │ └── list │ │ │ │ ├── ConfirmDeleteDialog.kt │ │ │ │ ├── RecipeItem.kt │ │ │ │ ├── RecipeListItemState.kt │ │ │ │ ├── RecipeListSnackbar.kt │ │ │ │ ├── RecipesList.kt │ │ │ │ ├── RecipesListError.kt │ │ │ │ ├── RecipesListViewModel.kt │ │ │ │ └── SearchTextField.kt │ │ │ └── share │ │ │ ├── ShareRecipeActivity.kt │ │ │ ├── ShareRecipeScreen.kt │ │ │ └── ShareRecipeViewModel.kt │ └── res │ │ ├── drawable-night │ │ ├── ic_splash_screen_background.xml │ │ └── ic_splash_screen_foreground.xml │ │ ├── drawable │ │ ├── ic_launcher_background.xml │ │ ├── ic_launcher_foreground.xml │ │ ├── ic_launcher_monochrome.xml │ │ ├── ic_progress_bar.xml │ │ ├── ic_splash_screen.xml │ │ ├── ic_splash_screen_background.xml │ │ ├── ic_splash_screen_foreground.xml │ │ └── placeholder_recipe.xml │ │ ├── mipmap │ │ └── ic_launcher.xml │ │ ├── values-de │ │ ├── plurals.xml │ │ └── strings.xml │ │ ├── values-es │ │ ├── plurals.xml │ │ └── strings.xml │ │ ├── values-fr │ │ ├── plurals.xml │ │ └── strings.xml │ │ ├── values-night │ │ └── themes.xml │ │ ├── values-nl │ │ ├── plurals.xml │ │ └── strings.xml │ │ ├── values-pt │ │ ├── plurals.xml │ │ └── strings.xml │ │ ├── values-ru │ │ ├── plurals.xml │ │ └── strings.xml │ │ ├── values-v31 │ │ └── drawable.xml │ │ ├── values │ │ ├── plurals.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── data_extraction_rules.xml │ │ ├── file_provider_paths.xml │ │ ├── full_backup_rules.xml │ │ ├── locales_config.xml │ │ └── network_security_config.xml │ └── test │ └── java │ └── gq │ └── kirmanak │ └── mealient │ ├── data │ ├── add │ │ └── impl │ │ │ └── AddRecipeRepoTest.kt │ ├── auth │ │ └── impl │ │ │ ├── AuthRepoImplTest.kt │ │ │ └── AuthStorageImplTest.kt │ ├── baseurl │ │ ├── ServerInfoRepoTest.kt │ │ └── ServerInfoStorageTest.kt │ ├── disclaimer │ │ └── DisclaimerStorageImplTest.kt │ ├── migration │ │ ├── From24AuthMigrationExecutorTest.kt │ │ └── MigrationDetectorImplTest.kt │ ├── network │ │ └── MealieDataSourceWrapperTest.kt │ ├── recipes │ │ └── impl │ │ │ ├── RecipeImageUrlProviderImplTest.kt │ │ │ ├── RecipePagingSourceFactoryImplTest.kt │ │ │ ├── RecipeRepoTest.kt │ │ │ └── RecipesRemoteMediatorTest.kt │ ├── share │ │ └── ShareRecipeRepoImplTest.kt │ └── storage │ │ └── PreferencesStorageImplTest.kt │ ├── test │ └── AuthImplTestData.kt │ └── ui │ ├── add │ └── AddRecipeViewModelTest.kt │ ├── baseurl │ └── BaseURLViewModelTest.kt │ ├── disclaimer │ └── DisclaimerViewModelTest.kt │ ├── recipes │ ├── RecipesListViewModelTest.kt │ └── info │ │ └── RecipeInfoViewModelTest.kt │ └── share │ └── ShareRecipeViewModelTest.kt ├── architecture ├── .gitignore ├── build.gradle.kts └── src │ ├── main │ └── kotlin │ │ └── gq │ │ └── kirmanak │ │ └── mealient │ │ └── architecture │ │ ├── FlowExtensions.kt │ │ └── configuration │ │ ├── AppDispatchers.kt │ │ ├── AppDispatchersImpl.kt │ │ ├── ArchitectureModule.kt │ │ └── BuildConfiguration.kt │ └── test │ └── kotlin │ └── gq │ └── kirmanak │ └── mealient │ └── architecture │ └── FlowExtensionsKtTest.kt ├── build-logic ├── convention │ ├── .gitignore │ ├── build.gradle.kts │ └── src │ │ └── main │ │ └── kotlin │ │ ├── AndroidApplicationComposeConventionPlugin.kt │ │ ├── AndroidApplicationConventionPlugin.kt │ │ ├── AndroidLibraryComposeConventionPlugin.kt │ │ ├── AndroidLibraryConventionPlugin.kt │ │ └── gq │ │ └── kirmanak │ │ └── mealient │ │ ├── AndroidCompose.kt │ │ ├── Extensions.kt │ │ ├── KotlinAndroid.kt │ │ └── Versions.kt ├── gradle.properties └── settings.gradle.kts ├── build.gradle.kts ├── crowdin.yml ├── database ├── .gitignore ├── build.gradle.kts └── src │ ├── main │ ├── AndroidManifest.xml │ └── kotlin │ │ └── gq │ │ └── kirmanak │ │ └── mealient │ │ └── database │ │ ├── AppDb.kt │ │ ├── DatabaseModule.kt │ │ ├── RoomTypeConverters.kt │ │ └── recipe │ │ ├── RecipeDao.kt │ │ ├── RecipeStorage.kt │ │ ├── RecipeStorageImpl.kt │ │ └── entity │ │ ├── RecipeEntity.kt │ │ ├── RecipeIngredientEntity.kt │ │ ├── RecipeIngredientToInstructionEntity.kt │ │ ├── RecipeInstructionEntity.kt │ │ ├── RecipeSummaryEntity.kt │ │ └── RecipeWithSummaryAndIngredientsAndInstructions.kt │ └── test │ └── kotlin │ └── gq │ └── kirmanak │ └── mealient │ └── database │ ├── RecipeStorageImplTest.kt │ └── RoomTypeConvertersTest.kt ├── database_test ├── .gitignore ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── gq │ └── kirmanak │ └── mealient │ └── database │ └── TestData.kt ├── datasource ├── .gitignore ├── build.gradle.kts ├── consumer-proguard-rules.pro └── src │ ├── debug │ ├── AndroidManifest.xml │ └── kotlin │ │ └── gq │ │ └── kirmanak │ │ └── mealient │ │ └── DebugModule.kt │ ├── main │ └── kotlin │ │ └── gq │ │ └── kirmanak │ │ └── mealient │ │ └── datasource │ │ ├── AuthenticationProvider.kt │ │ ├── CertificateCombinedException.kt │ │ ├── DataSourceExtensions.kt │ │ ├── DataSourceModule.kt │ │ ├── MealieDataSource.kt │ │ ├── MealieService.kt │ │ ├── NetworkError.kt │ │ ├── NetworkRequestWrapper.kt │ │ ├── ServerUrlProvider.kt │ │ ├── TokenChangeListener.kt │ │ ├── TrustedCertificatesStore.kt │ │ ├── impl │ │ ├── AdvancedX509TrustManager.kt │ │ ├── CacheBuilderImpl.kt │ │ ├── MealieDataSourceImpl.kt │ │ ├── MealieServiceKtor.kt │ │ ├── NetworkRequestWrapperImpl.kt │ │ ├── OkHttpBuilderImpl.kt │ │ ├── SslSocketFactoryFactory.kt │ │ └── TrustedCertificatesStoreImpl.kt │ │ ├── ktor │ │ ├── AuthKtorConfiguration.kt │ │ ├── ContentNegotiationConfiguration.kt │ │ ├── EncodingKtorConfiguration.kt │ │ ├── KtorClientBuilder.kt │ │ ├── KtorClientBuilderImpl.kt │ │ ├── KtorConfiguration.kt │ │ ├── KtorModule.kt │ │ └── TokenChangeListenerKtor.kt │ │ └── models │ │ ├── AddRecipeInfo.kt │ │ ├── CreateApiTokenRequest.kt │ │ ├── CreateApiTokenResponse.kt │ │ ├── CreateRecipeRequest.kt │ │ ├── CreateShoppingListItemRequest.kt │ │ ├── CreateShoppingListRequest.kt │ │ ├── ErrorDetail.kt │ │ ├── GetFoodsResponse.kt │ │ ├── GetItemLabelResponse.kt │ │ ├── GetRecipeResponse.kt │ │ ├── GetRecipeSummaryResponse.kt │ │ ├── GetRecipesResponse.kt │ │ ├── GetShoppingListResponse.kt │ │ ├── GetShoppingListsResponse.kt │ │ ├── GetShoppingListsSummaryResponse.kt │ │ ├── GetTokenResponse.kt │ │ ├── GetUnitsResponse.kt │ │ ├── GetUserInfoResponse.kt │ │ ├── ParseRecipeURLRequest.kt │ │ ├── UpdateRecipeRequest.kt │ │ └── VersionResponse.kt │ └── test │ └── kotlin │ └── gq │ └── kirmanak │ └── mealient │ └── datasource │ ├── AuthKtorConfigurationTest.kt │ └── FakeProvider.kt ├── datasource_test ├── .gitignore ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── gq │ └── kirmanak │ └── mealient │ └── datasource_test │ └── TestData.kt ├── datastore ├── .gitignore ├── build.gradle.kts └── src │ └── main │ ├── kotlin │ └── gq │ │ └── kirmanak │ │ └── mealient │ │ └── datastore │ │ ├── DataStoreModule.kt │ │ └── recipe │ │ ├── AddRecipeDraft.kt │ │ ├── AddRecipeInputSerializer.kt │ │ ├── AddRecipeStorage.kt │ │ └── AddRecipeStorageImpl.kt │ └── proto │ └── AddRecipeInput.proto ├── datastore_test ├── .gitignore ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── gq │ └── kirmanak │ └── mealient │ └── datastore_test │ └── TestData.kt ├── fastlane └── metadata │ └── android │ └── en-US │ ├── changelogs │ ├── 23.txt │ ├── 24.txt │ ├── 25.txt │ ├── 26.txt │ ├── 27.txt │ ├── 28.txt │ ├── 29.txt │ ├── 30.txt │ ├── 31.txt │ ├── 32.txt │ ├── 33.txt │ ├── 34.txt │ ├── 35.txt │ ├── 36.txt │ └── 37.txt │ ├── full_description.txt │ ├── images │ ├── icon.png │ └── phoneScreenshots │ │ ├── 1.png │ │ ├── 2.png │ │ ├── 3.png │ │ ├── 4.png │ │ ├── 5.png │ │ └── 6.png │ ├── short_description.txt │ └── title.txt ├── features └── shopping_lists │ ├── .gitignore │ ├── build.gradle.kts │ └── src │ ├── main │ ├── kotlin │ │ └── gq │ │ │ └── kirmanak │ │ │ └── mealient │ │ │ └── shopping_lists │ │ │ ├── ShoppingListsModule.kt │ │ │ ├── network │ │ │ ├── ShoppingListsDataSource.kt │ │ │ └── ShoppingListsDataSourceImpl.kt │ │ │ ├── repo │ │ │ ├── ShoppingListsAuthRepo.kt │ │ │ ├── ShoppingListsRepo.kt │ │ │ └── ShoppingListsRepoImpl.kt │ │ │ ├── ui │ │ │ ├── composables │ │ │ │ ├── EditableItemBox.kt │ │ │ │ ├── GetErrorMessage.kt │ │ │ │ └── MealientTextField.kt │ │ │ ├── details │ │ │ │ ├── ShoppingListData.kt │ │ │ │ ├── ShoppingListEditingState.kt │ │ │ │ ├── ShoppingListScreen.kt │ │ │ │ ├── ShoppingListScreenState.kt │ │ │ │ └── ShoppingListViewModel.kt │ │ │ └── list │ │ │ │ ├── DeleteListConfirmDialog.kt │ │ │ │ ├── ShoppingListNameDialog.kt │ │ │ │ ├── ShoppingListsScreen.kt │ │ │ │ └── ShoppingListsViewModel.kt │ │ │ └── util │ │ │ └── ShopingListLabelHelper.kt │ └── res │ │ ├── values-de │ │ └── strings.xml │ │ ├── values-es │ │ └── strings.xml │ │ ├── values-fr │ │ └── strings.xml │ │ ├── values-nl │ │ └── strings.xml │ │ ├── values-pt │ │ └── strings.xml │ │ ├── values-ru │ │ └── strings.xml │ │ └── values │ │ └── strings.xml │ └── test │ └── kotlin │ └── gq │ └── kirmanak │ └── mealient │ └── shopping_lists │ └── ui │ └── details │ └── ShoppingListViewModelTest.kt ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── lint.xml ├── logging ├── .gitignore ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── gq │ └── kirmanak │ └── mealient │ └── logging │ ├── Appender.kt │ ├── AppenderModule.kt │ ├── FileAppender.kt │ ├── LogLevel.kt │ ├── LogRedactor.kt │ ├── LogcatAppender.kt │ ├── Logger.kt │ ├── LoggerImpl.kt │ └── LoggerModule.kt ├── model_mapper ├── .gitignore ├── build.gradle.kts └── src │ ├── main │ └── kotlin │ │ └── gq │ │ └── kirmanak │ │ └── mealient │ │ └── model_mapper │ │ ├── ModelMapper.kt │ │ ├── ModelMapperImpl.kt │ │ └── ModelMapperModule.kt │ └── test │ └── kotlin │ └── gq │ └── kirmanak │ └── mealient │ └── model_mapper │ └── ModelMappingsTest.kt ├── renovate.json ├── settings.gradle.kts ├── template_module ├── .gitignore └── build.gradle.kts ├── testing ├── .gitignore ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── gq │ └── kirmanak │ └── mealient │ └── test │ ├── BaseUnitTest.kt │ ├── FakeLogger.kt │ ├── FakeLoggerModule.kt │ └── HiltRobolectricTest.kt └── ui ├── .gitignore ├── build.gradle.kts └── src └── main ├── kotlin └── gq │ └── kirmanak │ └── mealient │ └── ui │ ├── OperationUiState.kt │ ├── Theme.kt │ ├── UiModule.kt │ ├── components │ ├── BaseScreen.kt │ ├── CenteredProgressIndicator.kt │ ├── DrawerContent.kt │ ├── EmptyListError.kt │ ├── ErrorSnackbar.kt │ ├── LazyColumnPullRefresh.kt │ ├── LazyColumnWithLoadingState.kt │ ├── LazyPagingColumnPullRefresh.kt │ └── TopProgressIndicator.kt │ ├── preview │ └── ColorSchemePreview.kt │ └── util │ ├── LoadingHelper.kt │ ├── LoadingHelperFactory.kt │ ├── LoadingHelperFactoryImpl.kt │ ├── LoadingHelperImpl.kt │ └── LoadingState.kt └── res └── values └── strings.xml /.github/workflows/sign.yml: -------------------------------------------------------------------------------- 1 | name: Sign 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | sign: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | with: 14 | fetch-depth: 0 15 | 16 | - uses: actions/setup-java@v4 17 | with: 18 | distribution: temurin 19 | java-version: 17 20 | 21 | - name: Setup Gradle 22 | uses: gradle/gradle-build-action@v3 23 | 24 | - name: Setup Android SDK 25 | uses: android-actions/setup-android@v3 26 | 27 | - name: Restore keystore 28 | env: 29 | MEALIENT_KEY_STORE: ${{ secrets.MEALIENT_KEY_STORE }} 30 | MEALIENT_KEY_STORE_PASSWORD: ${{ secrets.MEALIENT_KEY_STORE_PASSWORD }} 31 | MEALIENT_KEY_ALIAS: ${{ secrets.MEALIENT_KEY_ALIAS }} 32 | MEALIENT_KEY_PASSWORD: ${{ secrets.MEALIENT_KEY_PASSWORD }} 33 | run: | 34 | echo "$MEALIENT_KEY_STORE" | base64 -d > app/keystore.jks 35 | echo "storeFile=keystore.jks" > keystore.properties 36 | echo "storePassword=$MEALIENT_KEY_STORE_PASSWORD" >> keystore.properties 37 | echo "keyAlias=$MEALIENT_KEY_ALIAS" >> keystore.properties 38 | echo "keyPassword=$MEALIENT_KEY_PASSWORD" >> keystore.properties 39 | 40 | - name: APK 41 | run: | 42 | ./gradlew build 43 | cp app/build/outputs/apk/release/*.apk mealient-release.apk 44 | 45 | - name: Bundle 46 | run: | 47 | ./gradlew bundle 48 | cp app/build/outputs/bundle/release/*.aab mealient-release.aab 49 | 50 | - name: Upload release build 51 | uses: actions/upload-artifact@v4 52 | with: 53 | name: Release build 54 | path: | 55 | mealient-release.apk 56 | mealient-release.aab 57 | 58 | - name: SonarCloud 59 | env: 60 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 61 | run: ./gradlew sonar -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | .idea 5 | .DS_Store 6 | /build 7 | /captures 8 | .externalNativeBuild 9 | .cxx 10 | local.properties 11 | .kotlin -------------------------------------------------------------------------------- /.run/Run tests.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 12 | 17 | 19 | false 20 | true 21 | false 22 | 23 | 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022, Kirill Kamakin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /PRIVACY_POLICY.md: -------------------------------------------------------------------------------- 1 | ## Mealient: Privacy policy 2 | 3 | Welcome to the Mealient app for Android! 4 | 5 | This is an open source Android app developed by Kirill Kamakin. The source code is available on 6 | GitHub under the MIT license; the app is also available on Google Play. 7 | 8 | I hereby state, to the best of my knowledge and belief, that I have not programmed this app to 9 | collect any personally identifiable information. All data created by the you (the user) is stored on 10 | the Mealie server(s) that you connect to. It can be removed by the administrator(s) of this (these) 11 | server(s). 12 | 13 | Yours sincerely, 14 | Kirill Kamakin. 15 | Stockholm, Sweden 16 | mealient@gmail.com -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/src/androidTest/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/src/androidTest/kotlin/gq/kirmanak/mealient/BaseTestCase.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient 2 | 3 | import androidx.compose.ui.test.junit4.createAndroidComposeRule 4 | import com.kaspersky.components.composesupport.config.withComposeSupport 5 | import com.kaspersky.kaspresso.kaspresso.Kaspresso 6 | import com.kaspersky.kaspresso.testcases.api.testcase.TestCase 7 | import dagger.hilt.android.testing.HiltAndroidRule 8 | import gq.kirmanak.mealient.ui.activity.MainActivity 9 | import okhttp3.mockwebserver.MockWebServer 10 | import org.junit.After 11 | import org.junit.Before 12 | import org.junit.Rule 13 | 14 | abstract class BaseTestCase : TestCase( 15 | kaspressoBuilder = Kaspresso.Builder.withComposeSupport(), 16 | ) { 17 | 18 | @get:Rule(order = 0) 19 | var hiltRule = HiltAndroidRule(this) 20 | 21 | @get:Rule(order = 1) 22 | val mainActivityRule = createAndroidComposeRule() 23 | 24 | lateinit var mockWebServer: MockWebServer 25 | 26 | @Before 27 | open fun setUp() { 28 | mockWebServer = MockWebServer() 29 | mockWebServer.start() 30 | } 31 | 32 | @After 33 | fun tearDown() { 34 | mockWebServer.shutdown() 35 | } 36 | 37 | } -------------------------------------------------------------------------------- /app/src/androidTest/kotlin/gq/kirmanak/mealient/MealientTestRunner.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import androidx.test.runner.AndroidJUnitRunner 6 | import dagger.hilt.android.testing.HiltTestApplication 7 | 8 | class MealientTestRunner : AndroidJUnitRunner() { 9 | 10 | override fun newApplication( 11 | cl: ClassLoader?, 12 | className: String?, 13 | context: Context?, 14 | ): Application = super.newApplication(cl, HiltTestApplication::class.java.name, context) 15 | } -------------------------------------------------------------------------------- /app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/AuthenticationScreen.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.screen 2 | 3 | import androidx.compose.ui.test.SemanticsNodeInteractionsProvider 4 | import io.github.kakaocup.compose.node.element.ComposeScreen 5 | import io.github.kakaocup.compose.node.element.KNode 6 | 7 | class AuthenticationScreen( 8 | semanticsProvider: SemanticsNodeInteractionsProvider, 9 | ) : ComposeScreen( 10 | semanticsProvider = semanticsProvider, 11 | viewBuilderAction = { hasTestTag("authentication-screen") }, 12 | ) { 13 | 14 | val emailInput = child { hasTestTag("email-input") } 15 | 16 | val passwordInput = child { hasTestTag("password-input") } 17 | 18 | val loginButton = child { hasTestTag("login-button") } 19 | 20 | } -------------------------------------------------------------------------------- /app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/BaseUrlScreen.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.screen 2 | 3 | import androidx.compose.ui.test.SemanticsNodeInteractionsProvider 4 | import io.github.kakaocup.compose.node.element.ComposeScreen 5 | import io.github.kakaocup.compose.node.element.KNode 6 | 7 | class BaseUrlScreen( 8 | semanticsProvider: SemanticsNodeInteractionsProvider, 9 | ) : ComposeScreen( 10 | semanticsProvider = semanticsProvider, 11 | viewBuilderAction = { hasTestTag("base-url-screen") }, 12 | ) { 13 | 14 | val urlInput = child { hasTestTag("url-input-field") } 15 | 16 | val urlInputLabel = unmergedChild { hasTestTag("url-input-label") } 17 | 18 | val proceedButton = child { hasTestTag("proceed-button") } 19 | 20 | val proceedButtonText = 21 | unmergedChild { hasTestTag("proceed-button-text") } 22 | 23 | val progressBar = unmergedChild { hasTestTag("progress-indicator") } 24 | 25 | } -------------------------------------------------------------------------------- /app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/DisclaimerScreen.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.screen 2 | 3 | import androidx.compose.ui.test.SemanticsNodeInteractionsProvider 4 | import io.github.kakaocup.compose.node.element.ComposeScreen 5 | import io.github.kakaocup.compose.node.element.KNode 6 | 7 | internal class DisclaimerScreen( 8 | semanticsProvider: SemanticsNodeInteractionsProvider, 9 | ) : ComposeScreen( 10 | semanticsProvider = semanticsProvider, 11 | viewBuilderAction = { hasTestTag("disclaimer-screen") }, 12 | ) { 13 | 14 | val okayButton = child { hasTestTag("okay-button") } 15 | 16 | val okayButtonText = unmergedChild { hasTestTag("okay-button-text") } 17 | 18 | val disclaimerText = child { hasTestTag("disclaimer-text") } 19 | } -------------------------------------------------------------------------------- /app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/Extensions.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.screen 2 | 3 | import io.github.kakaocup.compose.node.builder.ViewBuilder 4 | import io.github.kakaocup.compose.node.core.BaseNode 5 | 6 | 7 | inline fun > BaseNode.unmergedChild( 8 | function: ViewBuilder.() -> Unit, 9 | ): N = child { 10 | useUnmergedTree = true 11 | function() 12 | } -------------------------------------------------------------------------------- /app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/RecipesListScreen.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.screen 2 | 3 | import androidx.compose.ui.test.SemanticsNodeInteractionsProvider 4 | import io.github.kakaocup.compose.node.element.ComposeScreen 5 | import io.github.kakaocup.compose.node.element.KNode 6 | 7 | internal class RecipesListScreen( 8 | semanticsProvider: SemanticsNodeInteractionsProvider, 9 | ) : ComposeScreen(semanticsProvider) { 10 | 11 | val openDrawerButton = child { hasTestTag("open-drawer-button") } 12 | 13 | val searchRecipesField = child { hasTestTag("search-recipes-field") } 14 | 15 | val emptyListErrorText = unmergedChild { 16 | hasTestTag("empty-list-error-text") 17 | } 18 | } -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kirmanak/Mealient/2dd0ec34030716a4038f6d482439aa1a3cc1a08d/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/App.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient 2 | 3 | import android.app.Application 4 | import coil.Coil 5 | import coil.ImageLoader 6 | import com.google.android.material.color.DynamicColors 7 | import dagger.hilt.android.HiltAndroidApp 8 | import gq.kirmanak.mealient.architecture.configuration.BuildConfiguration 9 | import gq.kirmanak.mealient.data.migration.MigrationDetector 10 | import gq.kirmanak.mealient.logging.Logger 11 | import kotlinx.coroutines.CoroutineScope 12 | import kotlinx.coroutines.Dispatchers 13 | import kotlinx.coroutines.Job 14 | import kotlinx.coroutines.launch 15 | import javax.inject.Inject 16 | 17 | @HiltAndroidApp 18 | class App : Application() { 19 | 20 | @Inject 21 | lateinit var logger: Logger 22 | 23 | @Inject 24 | lateinit var buildConfiguration: BuildConfiguration 25 | 26 | @Inject 27 | lateinit var migrationDetector: MigrationDetector 28 | 29 | @Inject 30 | lateinit var imageLoader: ImageLoader 31 | 32 | private val appCoroutineScope = CoroutineScope(Dispatchers.Main + Job()) 33 | 34 | override fun onCreate() { 35 | super.onCreate() 36 | logger.v { "onCreate() called" } 37 | DynamicColors.applyToActivitiesIfAvailable(this) 38 | appCoroutineScope.launch { migrationDetector.executeMigrations() } 39 | Coil.setImageLoader(imageLoader) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/add/AddRecipeDataSource.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.add 2 | 3 | import gq.kirmanak.mealient.datasource.models.AddRecipeInfo 4 | 5 | interface AddRecipeDataSource { 6 | 7 | suspend fun addRecipe(recipe: AddRecipeInfo): String 8 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/add/AddRecipeRepo.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.add 2 | 3 | import gq.kirmanak.mealient.datasource.models.AddRecipeInfo 4 | import kotlinx.coroutines.flow.Flow 5 | 6 | interface AddRecipeRepo { 7 | 8 | val addRecipeRequestFlow: Flow 9 | 10 | suspend fun preserve(recipe: AddRecipeInfo) 11 | 12 | suspend fun clear() 13 | 14 | suspend fun saveRecipe(): String 15 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/add/impl/AddRecipeRepoImpl.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.add.impl 2 | 3 | import gq.kirmanak.mealient.data.add.AddRecipeDataSource 4 | import gq.kirmanak.mealient.data.add.AddRecipeRepo 5 | import gq.kirmanak.mealient.datasource.models.AddRecipeInfo 6 | import gq.kirmanak.mealient.datastore.recipe.AddRecipeStorage 7 | import gq.kirmanak.mealient.logging.Logger 8 | import gq.kirmanak.mealient.model_mapper.ModelMapper 9 | import kotlinx.coroutines.flow.Flow 10 | import kotlinx.coroutines.flow.first 11 | import kotlinx.coroutines.flow.map 12 | import javax.inject.Inject 13 | 14 | class AddRecipeRepoImpl @Inject constructor( 15 | private val addRecipeDataSource: AddRecipeDataSource, 16 | private val addRecipeStorage: AddRecipeStorage, 17 | private val logger: Logger, 18 | private val modelMapper: ModelMapper, 19 | ) : AddRecipeRepo { 20 | 21 | override val addRecipeRequestFlow: Flow 22 | get() = addRecipeStorage.updates.map { modelMapper.toAddRecipeInfo(it) } 23 | 24 | override suspend fun preserve(recipe: AddRecipeInfo) { 25 | logger.v { "preserveRecipe() called with: recipe = $recipe" } 26 | addRecipeStorage.save(modelMapper.toDraft(recipe)) 27 | } 28 | 29 | override suspend fun clear() { 30 | logger.v { "clear() called" } 31 | addRecipeStorage.clear() 32 | } 33 | 34 | override suspend fun saveRecipe(): String { 35 | logger.v { "saveRecipe() called" } 36 | return addRecipeDataSource.addRecipe(addRecipeRequestFlow.first()) 37 | } 38 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/auth/AuthDataSource.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.auth 2 | 3 | interface AuthDataSource { 4 | /** 5 | * Tries to acquire authentication token using the provided credentials 6 | */ 7 | suspend fun authenticate(username: String, password: String): String 8 | 9 | suspend fun createApiToken(name: String): String 10 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/auth/AuthRepo.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.auth 2 | 3 | import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsAuthRepo 4 | import kotlinx.coroutines.flow.Flow 5 | 6 | interface AuthRepo : ShoppingListsAuthRepo { 7 | 8 | override val isAuthorizedFlow: Flow 9 | 10 | suspend fun authenticate(email: String, password: String) 11 | 12 | suspend fun getAuthToken(): String? 13 | 14 | suspend fun logout() 15 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/auth/AuthStorage.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.auth 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | 5 | interface AuthStorage { 6 | 7 | val authTokenFlow: Flow 8 | 9 | suspend fun setAuthToken(authToken: String?) 10 | 11 | suspend fun getAuthToken(): String? 12 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImpl.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.auth.impl 2 | 3 | import gq.kirmanak.mealient.data.auth.AuthDataSource 4 | import gq.kirmanak.mealient.datasource.MealieDataSource 5 | import gq.kirmanak.mealient.datasource.models.CreateApiTokenRequest 6 | import javax.inject.Inject 7 | 8 | class AuthDataSourceImpl @Inject constructor( 9 | private val dataSource: MealieDataSource, 10 | ) : AuthDataSource { 11 | 12 | override suspend fun authenticate(username: String, password: String): String { 13 | return dataSource.authenticate(username, password) 14 | } 15 | 16 | override suspend fun createApiToken(name: String): String { 17 | return dataSource.createApiToken(CreateApiTokenRequest(name)).token 18 | } 19 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImpl.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.auth.impl 2 | 3 | import gq.kirmanak.mealient.data.auth.AuthDataSource 4 | import gq.kirmanak.mealient.data.auth.AuthRepo 5 | import gq.kirmanak.mealient.data.auth.AuthStorage 6 | import gq.kirmanak.mealient.datasource.AuthenticationProvider 7 | import gq.kirmanak.mealient.logging.Logger 8 | import kotlinx.coroutines.flow.Flow 9 | import kotlinx.coroutines.flow.map 10 | import javax.inject.Inject 11 | 12 | class AuthRepoImpl @Inject constructor( 13 | private val authStorage: AuthStorage, 14 | private val authDataSource: AuthDataSource, 15 | private val logger: Logger, 16 | private val credentialsLogRedactor: CredentialsLogRedactor, 17 | ) : AuthRepo, AuthenticationProvider { 18 | 19 | override val isAuthorizedFlow: Flow 20 | get() = authStorage.authTokenFlow.map { it != null } 21 | 22 | override suspend fun authenticate(email: String, password: String) { 23 | logger.v { "authenticate() called" } 24 | 25 | credentialsLogRedactor.set(email, password) 26 | val token = authDataSource.authenticate(email, password) 27 | credentialsLogRedactor.clear() 28 | authStorage.setAuthToken(token) 29 | 30 | val apiToken = authDataSource.createApiToken(API_TOKEN_NAME) 31 | authStorage.setAuthToken(apiToken) 32 | } 33 | 34 | override suspend fun getAuthToken(): String? = authStorage.getAuthToken() 35 | 36 | override suspend fun logout() { 37 | logger.v { "logout() called" } 38 | authStorage.setAuthToken(null) 39 | } 40 | 41 | companion object { 42 | private const val API_TOKEN_NAME = "Mealient" 43 | } 44 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/auth/impl/CredentialsLogRedactor.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.auth.impl 2 | 3 | import gq.kirmanak.mealient.logging.LogRedactor 4 | import kotlinx.coroutines.flow.MutableStateFlow 5 | import java.net.URLEncoder 6 | import javax.inject.Inject 7 | import javax.inject.Singleton 8 | 9 | @Singleton 10 | class CredentialsLogRedactor @Inject constructor() : LogRedactor { 11 | 12 | private data class Credentials( 13 | val login: String, 14 | val password: String, 15 | val urlEncodedLogin: String = URLEncoder.encode(login, Charsets.UTF_8.name()), 16 | val urlEncodedPassword: String = URLEncoder.encode(password, Charsets.UTF_8.name()), 17 | ) 18 | 19 | private val credentialsState = MutableStateFlow(null) 20 | 21 | fun set(login: String, password: String) { 22 | credentialsState.value = Credentials(login, password) 23 | } 24 | 25 | fun clear() { 26 | credentialsState.value = null 27 | } 28 | 29 | override fun redact(message: String): String { 30 | val credentials = credentialsState.value ?: return message 31 | 32 | return message 33 | .replace(credentials.login, "") 34 | .replace(credentials.urlEncodedLogin, "") 35 | .replace(credentials.password, "") 36 | .replace(credentials.urlEncodedPassword, "") 37 | } 38 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/baseurl/ServerInfoRepo.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.baseurl 2 | 3 | import gq.kirmanak.mealient.datasource.models.VersionResponse 4 | import kotlinx.coroutines.flow.Flow 5 | 6 | interface ServerInfoRepo { 7 | 8 | val baseUrlFlow: Flow 9 | 10 | suspend fun getUrl(): String? 11 | 12 | suspend fun tryBaseURL(baseURL: String): Result 13 | 14 | } 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/baseurl/ServerInfoRepoImpl.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.baseurl 2 | 3 | import gq.kirmanak.mealient.datasource.ServerUrlProvider 4 | import gq.kirmanak.mealient.datasource.models.VersionResponse 5 | import gq.kirmanak.mealient.logging.Logger 6 | import kotlinx.coroutines.flow.Flow 7 | import javax.inject.Inject 8 | 9 | class ServerInfoRepoImpl @Inject constructor( 10 | private val serverInfoStorage: ServerInfoStorage, 11 | private val versionDataSource: VersionDataSource, 12 | private val logger: Logger, 13 | ) : ServerInfoRepo, ServerUrlProvider { 14 | 15 | override val baseUrlFlow: Flow 16 | get() = serverInfoStorage.baseUrlFlow 17 | 18 | override suspend fun getUrl(): String? { 19 | val result = serverInfoStorage.getBaseURL() 20 | logger.v { "getUrl() returned: $result" } 21 | return result 22 | } 23 | 24 | override suspend fun tryBaseURL(baseURL: String): Result { 25 | return versionDataSource.runCatching { 26 | requestVersion(baseURL) 27 | }.onSuccess { 28 | serverInfoStorage.storeBaseURL(baseURL) 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/baseurl/ServerInfoStorage.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.baseurl 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | 5 | interface ServerInfoStorage { 6 | 7 | val baseUrlFlow: Flow 8 | 9 | suspend fun getBaseURL(): String? 10 | 11 | suspend fun storeBaseURL(baseURL: String?) 12 | 13 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/baseurl/VersionDataSource.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.baseurl 2 | 3 | import gq.kirmanak.mealient.datasource.models.VersionResponse 4 | 5 | interface VersionDataSource { 6 | 7 | suspend fun requestVersion(baseURL: String): VersionResponse 8 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/baseurl/VersionDataSourceImpl.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.baseurl 2 | 3 | import gq.kirmanak.mealient.datasource.MealieDataSource 4 | import gq.kirmanak.mealient.datasource.models.VersionResponse 5 | import javax.inject.Inject 6 | 7 | class VersionDataSourceImpl @Inject constructor( 8 | private val dataSource: MealieDataSource, 9 | ) : VersionDataSource { 10 | 11 | override suspend fun requestVersion(baseURL: String): VersionResponse { 12 | return dataSource.getVersionInfo(baseURL) 13 | } 14 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/baseurl/impl/BaseUrlLogRedactor.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.baseurl.impl 2 | 3 | import androidx.core.net.toUri 4 | import gq.kirmanak.mealient.architecture.configuration.AppDispatchers 5 | import gq.kirmanak.mealient.data.storage.PreferencesStorage 6 | import gq.kirmanak.mealient.logging.LogRedactor 7 | import kotlinx.coroutines.CoroutineScope 8 | import kotlinx.coroutines.SupervisorJob 9 | import kotlinx.coroutines.flow.MutableStateFlow 10 | import kotlinx.coroutines.launch 11 | import javax.inject.Inject 12 | import javax.inject.Provider 13 | import javax.inject.Singleton 14 | 15 | @Singleton 16 | class BaseUrlLogRedactor @Inject constructor( 17 | private val preferencesStorageProvider: Provider, 18 | private val dispatchers: AppDispatchers, 19 | ) : LogRedactor { 20 | 21 | private val hostState = MutableStateFlow(null) 22 | private val preferencesStorage: PreferencesStorage 23 | get() = preferencesStorageProvider.get() 24 | 25 | init { 26 | setInitialBaseUrl() 27 | } 28 | 29 | private fun setInitialBaseUrl() { 30 | val scope = CoroutineScope(dispatchers.default + SupervisorJob()) 31 | scope.launch { 32 | val baseUrl = preferencesStorage.getValue(preferencesStorage.baseUrlKey) 33 | hostState.compareAndSet( 34 | expect = null, 35 | update = baseUrl?.extractHost() 36 | ) 37 | } 38 | } 39 | 40 | fun set(baseUrl: String) { 41 | hostState.value = baseUrl.extractHost() 42 | } 43 | 44 | override fun redact(message: String): String { 45 | val host = hostState.value 46 | return when { 47 | host == null && message.contains(preferencesStorage.baseUrlKey.name) -> "" 48 | host == null -> message 49 | else -> message.replace(host, "") 50 | } 51 | } 52 | } 53 | 54 | private fun String.extractHost() = runCatching { toUri() }.getOrNull()?.host 55 | -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/baseurl/impl/ServerInfoStorageImpl.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.baseurl.impl 2 | 3 | import androidx.datastore.preferences.core.Preferences 4 | import gq.kirmanak.mealient.data.baseurl.ServerInfoStorage 5 | import gq.kirmanak.mealient.data.storage.PreferencesStorage 6 | import kotlinx.coroutines.flow.Flow 7 | import javax.inject.Inject 8 | 9 | class ServerInfoStorageImpl @Inject constructor( 10 | private val preferencesStorage: PreferencesStorage, 11 | ) : ServerInfoStorage { 12 | 13 | private val baseUrlKey: Preferences.Key 14 | get() = preferencesStorage.baseUrlKey 15 | 16 | override val baseUrlFlow: Flow 17 | get() = preferencesStorage.valueUpdates(baseUrlKey) 18 | 19 | override suspend fun getBaseURL(): String? = getValue(baseUrlKey) 20 | 21 | override suspend fun storeBaseURL(baseURL: String?) { 22 | if (baseURL == null) { 23 | preferencesStorage.removeValues(baseUrlKey) 24 | } else { 25 | preferencesStorage.storeValues(Pair(baseUrlKey, baseURL)) 26 | } 27 | } 28 | 29 | private suspend fun getValue(key: Preferences.Key): T? = preferencesStorage.getValue(key) 30 | 31 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/configuration/BuildConfigurationImpl.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.configuration 2 | 3 | import gq.kirmanak.mealient.BuildConfig 4 | import gq.kirmanak.mealient.architecture.configuration.BuildConfiguration 5 | import javax.inject.Inject 6 | 7 | class BuildConfigurationImpl @Inject constructor() : BuildConfiguration { 8 | 9 | override fun isDebug(): Boolean = BuildConfig.DEBUG 10 | 11 | override fun versionCode(): Int = BuildConfig.VERSION_CODE 12 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/disclaimer/DisclaimerStorage.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.disclaimer 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | 5 | interface DisclaimerStorage { 6 | 7 | val isDisclaimerAcceptedFlow: Flow 8 | 9 | suspend fun isDisclaimerAccepted(): Boolean 10 | 11 | suspend fun acceptDisclaimer() 12 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/disclaimer/DisclaimerStorageImpl.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.disclaimer 2 | 3 | import androidx.datastore.preferences.core.Preferences 4 | import gq.kirmanak.mealient.data.storage.PreferencesStorage 5 | import gq.kirmanak.mealient.logging.Logger 6 | import kotlinx.coroutines.flow.Flow 7 | import kotlinx.coroutines.flow.map 8 | import javax.inject.Inject 9 | 10 | class DisclaimerStorageImpl @Inject constructor( 11 | private val preferencesStorage: PreferencesStorage, 12 | private val logger: Logger, 13 | ) : DisclaimerStorage { 14 | 15 | private val isDisclaimerAcceptedKey: Preferences.Key 16 | get() = preferencesStorage.isDisclaimerAcceptedKey 17 | override val isDisclaimerAcceptedFlow: Flow 18 | get() = preferencesStorage.valueUpdates(isDisclaimerAcceptedKey).map { it == true } 19 | 20 | override suspend fun isDisclaimerAccepted(): Boolean { 21 | logger.v { "isDisclaimerAccepted() called" } 22 | val isAccepted = preferencesStorage.getValue(isDisclaimerAcceptedKey) ?: false 23 | logger.v { "isDisclaimerAccepted() returned: $isAccepted" } 24 | return isAccepted 25 | } 26 | 27 | override suspend fun acceptDisclaimer() { 28 | logger.v { "acceptDisclaimer() called" } 29 | preferencesStorage.storeValues(Pair(isDisclaimerAcceptedKey, true)) 30 | } 31 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/migration/From24AuthMigrationExecutor.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.migration 2 | 3 | import android.content.SharedPreferences 4 | import androidx.core.content.edit 5 | import gq.kirmanak.mealient.data.auth.AuthRepo 6 | import gq.kirmanak.mealient.datasource.runCatchingExceptCancel 7 | import gq.kirmanak.mealient.datastore.DataStoreModule.Companion.ENCRYPTED 8 | import gq.kirmanak.mealient.logging.Logger 9 | import javax.inject.Inject 10 | import javax.inject.Named 11 | 12 | class From24AuthMigrationExecutor @Inject constructor( 13 | @Named(ENCRYPTED) private val sharedPreferences: SharedPreferences, 14 | private val authRepo: AuthRepo, 15 | private val logger: Logger, 16 | ) : MigrationExecutor { 17 | 18 | override val migratingFrom: Int = 24 19 | 20 | override suspend fun executeMigration() { 21 | logger.v { "executeMigration() was called" } 22 | val email = sharedPreferences.getString(EMAIL_KEY, null) 23 | val password = sharedPreferences.getString(PASSWORD_KEY, null) 24 | if (email != null && password != null) { 25 | runCatchingExceptCancel { authRepo.authenticate(email, password) } 26 | .onFailure { logger.e(it) { "API token creation failed" } } 27 | .onSuccess { logger.i { "Created API token during migration" } } 28 | } 29 | sharedPreferences.edit { 30 | remove(EMAIL_KEY) 31 | remove(PASSWORD_KEY) 32 | } 33 | } 34 | 35 | companion object { 36 | private const val EMAIL_KEY = "email" 37 | private const val PASSWORD_KEY = "password" 38 | } 39 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/migration/From30MigrationExecutor.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.migration 2 | 3 | import android.content.SharedPreferences 4 | import androidx.core.content.edit 5 | import androidx.datastore.core.DataStore 6 | import androidx.datastore.preferences.core.Preferences 7 | import androidx.datastore.preferences.core.edit 8 | import androidx.datastore.preferences.core.stringPreferencesKey 9 | import gq.kirmanak.mealient.datastore.DataStoreModule 10 | import javax.inject.Inject 11 | import javax.inject.Named 12 | 13 | class From30MigrationExecutor @Inject constructor( 14 | @Named(DataStoreModule.ENCRYPTED) private val sharedPreferences: SharedPreferences, 15 | private val dataStore: DataStore, 16 | ) : MigrationExecutor { 17 | 18 | override val migratingFrom: Int = 30 19 | 20 | override suspend fun executeMigration() { 21 | dataStore.edit { prefs -> 22 | prefs -= stringPreferencesKey("serverVersion") 23 | } 24 | val authHeader = sharedPreferences.getString("authHeader", null) 25 | if (authHeader != null) { 26 | sharedPreferences.edit { 27 | val authToken = authHeader.removePrefix("Bearer ") 28 | putString("authToken", authToken) 29 | } 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/migration/MigrationDetector.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.migration 2 | 3 | interface MigrationDetector { 4 | 5 | suspend fun executeMigrations() 6 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/migration/MigrationDetectorImpl.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.migration 2 | 3 | import gq.kirmanak.mealient.architecture.configuration.BuildConfiguration 4 | import gq.kirmanak.mealient.data.storage.PreferencesStorage 5 | import gq.kirmanak.mealient.datasource.runCatchingExceptCancel 6 | import gq.kirmanak.mealient.logging.Logger 7 | import javax.inject.Inject 8 | 9 | class MigrationDetectorImpl @Inject constructor( 10 | private val preferencesStorage: PreferencesStorage, 11 | private val migrationExecutors: Set<@JvmSuppressWildcards MigrationExecutor>, 12 | private val buildConfiguration: BuildConfiguration, 13 | private val logger: Logger, 14 | ) : MigrationDetector { 15 | 16 | override suspend fun executeMigrations() { 17 | val key = preferencesStorage.lastExecutedMigrationVersionKey 18 | 19 | val lastVersion = preferencesStorage.getValue(key) ?: VERSION_BEFORE_MIGRATION_IMPLEMENTED 20 | val currentVersion = buildConfiguration.versionCode() 21 | logger.i { "Last migration version is $lastVersion, current is $currentVersion" } 22 | 23 | if (lastVersion != currentVersion) { 24 | migrationExecutors 25 | .filter { it.migratingFrom >= lastVersion } 26 | .forEach { executor -> 27 | runCatchingExceptCancel { executor.executeMigration() } 28 | .onFailure { logger.e(it) { "Migration executor failed: $executor" } } 29 | .onSuccess { logger.i { "Migration executor succeeded: $executor" } } 30 | } 31 | } 32 | 33 | preferencesStorage.storeValues(Pair(key, currentVersion)) 34 | } 35 | 36 | 37 | companion object { 38 | private const val VERSION_BEFORE_MIGRATION_IMPLEMENTED = 24 39 | } 40 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/migration/MigrationExecutor.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.migration 2 | 3 | interface MigrationExecutor { 4 | 5 | val migratingFrom: Int 6 | 7 | suspend fun executeMigration() 8 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/recipes/RecipeRepo.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.recipes 2 | 3 | import androidx.paging.Pager 4 | import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity 5 | import gq.kirmanak.mealient.database.recipe.entity.RecipeWithSummaryAndIngredientsAndInstructions 6 | 7 | interface RecipeRepo { 8 | 9 | fun createPager(): Pager 10 | 11 | suspend fun clearLocalData() 12 | 13 | suspend fun refreshRecipeInfo(recipeSlug: String): Result 14 | 15 | suspend fun loadRecipeInfo(recipeId: String): RecipeWithSummaryAndIngredientsAndInstructions? 16 | 17 | fun updateNameQuery(name: String?) 18 | 19 | suspend fun refreshRecipes() 20 | 21 | suspend fun updateIsRecipeFavorite(recipeSlug: String, isFavorite: Boolean): Result 22 | 23 | suspend fun deleteRecipe(entity: RecipeSummaryEntity): Result 24 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeImageUrlProvider.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.recipes.impl 2 | 3 | interface RecipeImageUrlProvider { 4 | 5 | suspend fun generateImageUrl(imageId: String?): String? 6 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeImageUrlProviderImpl.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.recipes.impl 2 | 3 | import android.net.Uri 4 | import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo 5 | import gq.kirmanak.mealient.logging.Logger 6 | import javax.inject.Inject 7 | 8 | class RecipeImageUrlProviderImpl @Inject constructor( 9 | private val serverInfoRepo: ServerInfoRepo, 10 | private val logger: Logger, 11 | ) : RecipeImageUrlProvider { 12 | 13 | override suspend fun generateImageUrl(imageId: String?): String? { 14 | logger.v { "generateImageUrl() called with: slug = $imageId" } 15 | imageId?.takeUnless { it.isBlank() } ?: return null 16 | val imagePath = IMAGE_PATH_FORMAT.format(imageId) 17 | val baseUrl = serverInfoRepo.getUrl()?.takeUnless { it.isEmpty() } 18 | val result = baseUrl 19 | ?.takeUnless { it.isBlank() } 20 | ?.let { Uri.parse(it) } 21 | ?.buildUpon() 22 | ?.path(imagePath) 23 | ?.build() 24 | ?.toString() 25 | logger.v { "getRecipeImageUrl() returned: $result" } 26 | return result 27 | } 28 | 29 | companion object { 30 | private const val IMAGE_PATH_FORMAT = "api/media/recipes/%s/images/original.webp" 31 | } 32 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipePagingSourceFactory.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.recipes.impl 2 | 3 | import androidx.paging.PagingSource 4 | import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity 5 | 6 | interface RecipePagingSourceFactory : () -> PagingSource { 7 | fun setQuery(newQuery: String?) 8 | fun invalidate() 9 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipePagingSourceFactoryImpl.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.recipes.impl 2 | 3 | import androidx.paging.InvalidatingPagingSourceFactory 4 | import gq.kirmanak.mealient.database.recipe.RecipeStorage 5 | import gq.kirmanak.mealient.logging.Logger 6 | import java.util.concurrent.atomic.AtomicReference 7 | import javax.inject.Inject 8 | import javax.inject.Singleton 9 | 10 | @Singleton 11 | class RecipePagingSourceFactoryImpl @Inject constructor( 12 | private val recipeStorage: RecipeStorage, 13 | private val logger: Logger, 14 | ) : RecipePagingSourceFactory { 15 | 16 | private val query = AtomicReference(null) 17 | 18 | private val delegate = InvalidatingPagingSourceFactory { 19 | val currentQuery = query.get() 20 | logger.d { "Creating paging source, query is $currentQuery" } 21 | recipeStorage.queryRecipes(currentQuery) 22 | } 23 | 24 | override fun invoke() = delegate.invoke() 25 | 26 | override fun setQuery(newQuery: String?) { 27 | logger.v { "setQuery() called with: newQuery = $newQuery" } 28 | query.set(newQuery) 29 | invalidate() 30 | } 31 | 32 | override fun invalidate() = delegate.invalidate() 33 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSource.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.recipes.network 2 | 3 | import gq.kirmanak.mealient.datasource.models.GetRecipeResponse 4 | import gq.kirmanak.mealient.datasource.models.GetRecipeSummaryResponse 5 | 6 | interface RecipeDataSource { 7 | suspend fun requestRecipes(start: Int, limit: Int): List 8 | 9 | suspend fun requestRecipe(slug: String): GetRecipeResponse 10 | 11 | suspend fun getFavoriteRecipes(): List 12 | 13 | suspend fun updateIsRecipeFavorite(recipeSlug: String, isFavorite: Boolean) 14 | 15 | suspend fun deleteRecipe(recipeSlug: String) 16 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/share/ParseRecipeDataSource.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.share 2 | 3 | import gq.kirmanak.mealient.datasource.models.ParseRecipeURLRequest 4 | 5 | interface ParseRecipeDataSource { 6 | 7 | suspend fun parseRecipeFromURL(parseRecipeURLInfo: ParseRecipeURLRequest): String 8 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/share/ShareRecipeRepo.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.share 2 | 3 | interface ShareRecipeRepo { 4 | 5 | suspend fun saveRecipeByURL(url: CharSequence): String 6 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/share/ShareRecipeRepoImpl.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.share 2 | 3 | import androidx.core.util.PatternsCompat 4 | import gq.kirmanak.mealient.datasource.models.ParseRecipeURLRequest 5 | import gq.kirmanak.mealient.logging.Logger 6 | import javax.inject.Inject 7 | 8 | class ShareRecipeRepoImpl @Inject constructor( 9 | private val logger: Logger, 10 | private val parseRecipeDataSource: ParseRecipeDataSource, 11 | ) : ShareRecipeRepo { 12 | 13 | override suspend fun saveRecipeByURL(url: CharSequence): String { 14 | logger.v { "saveRecipeByURL() called with: url = $url" } 15 | val matcher = PatternsCompat.WEB_URL.matcher(url) 16 | require(matcher.find()) { "Can't find URL in the text" } 17 | val urlString = matcher.group() 18 | val request = ParseRecipeURLRequest(url = urlString, includeTags = true) 19 | return parseRecipeDataSource.parseRecipeFromURL(request) 20 | } 21 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/data/storage/PreferencesStorage.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.storage 2 | 3 | import androidx.datastore.preferences.core.Preferences 4 | import kotlinx.coroutines.flow.Flow 5 | 6 | interface PreferencesStorage { 7 | 8 | val baseUrlKey: Preferences.Key 9 | 10 | val isDisclaimerAcceptedKey: Preferences.Key 11 | 12 | val lastExecutedMigrationVersionKey: Preferences.Key 13 | 14 | suspend fun getValue(key: Preferences.Key): T? 15 | 16 | suspend fun requireValue(key: Preferences.Key): T 17 | 18 | suspend fun storeValues(vararg pairs: Pair, T>) 19 | 20 | fun valueUpdates(key: Preferences.Key): Flow 21 | 22 | suspend fun removeValues(vararg keys: Preferences.Key) 23 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/di/AddRecipeModule.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.di 2 | 3 | import dagger.Binds 4 | import dagger.Module 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | import gq.kirmanak.mealient.data.add.AddRecipeDataSource 8 | import gq.kirmanak.mealient.data.add.AddRecipeRepo 9 | import gq.kirmanak.mealient.data.add.impl.AddRecipeRepoImpl 10 | import gq.kirmanak.mealient.data.network.MealieDataSourceWrapper 11 | import gq.kirmanak.mealient.datastore.recipe.AddRecipeStorage 12 | import gq.kirmanak.mealient.datastore.recipe.AddRecipeStorageImpl 13 | 14 | @Module 15 | @InstallIn(SingletonComponent::class) 16 | interface AddRecipeModule { 17 | 18 | 19 | @Binds 20 | fun provideAddRecipeRepo(repo: AddRecipeRepoImpl): AddRecipeRepo 21 | 22 | @Binds 23 | fun bindAddRecipeDataSource(mealieDataSourceWrapper: MealieDataSourceWrapper): AddRecipeDataSource 24 | 25 | @Binds 26 | fun bindAddRecipeStorage(addRecipeStorageImpl: AddRecipeStorageImpl): AddRecipeStorage 27 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/di/AppModule.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.di 2 | 3 | import android.content.Context 4 | import androidx.datastore.core.DataStore 5 | import androidx.datastore.preferences.core.PreferenceDataStoreFactory 6 | import androidx.datastore.preferences.core.Preferences 7 | import androidx.datastore.preferences.preferencesDataStoreFile 8 | import coil.ImageLoader 9 | import dagger.Binds 10 | import dagger.Module 11 | import dagger.Provides 12 | import dagger.hilt.InstallIn 13 | import dagger.hilt.android.qualifiers.ApplicationContext 14 | import dagger.hilt.components.SingletonComponent 15 | import gq.kirmanak.mealient.data.storage.PreferencesStorage 16 | import gq.kirmanak.mealient.data.storage.PreferencesStorageImpl 17 | import okhttp3.OkHttpClient 18 | import javax.inject.Singleton 19 | 20 | @Module 21 | @InstallIn(SingletonComponent::class) 22 | interface AppModule { 23 | companion object { 24 | 25 | @Provides 26 | @Singleton 27 | fun provideDataStore(@ApplicationContext context: Context): DataStore = 28 | PreferenceDataStoreFactory.create { context.preferencesDataStoreFile("settings") } 29 | 30 | @Provides 31 | @Singleton 32 | fun provideCoilImageLoader( 33 | @ApplicationContext context: Context, 34 | okHttpClient: OkHttpClient, 35 | ): ImageLoader { 36 | return ImageLoader.Builder(context) 37 | .okHttpClient(okHttpClient) 38 | .build() 39 | } 40 | } 41 | 42 | @Binds 43 | fun bindPreferencesStorage(preferencesStorage: PreferencesStorageImpl): PreferencesStorage 44 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/di/ArchitectureModule.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.di 2 | 3 | import dagger.Binds 4 | import dagger.Module 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | import gq.kirmanak.mealient.architecture.configuration.BuildConfiguration 8 | import gq.kirmanak.mealient.data.configuration.BuildConfigurationImpl 9 | 10 | @Module 11 | @InstallIn(SingletonComponent::class) 12 | interface ArchitectureModule { 13 | 14 | @Binds 15 | fun bindBuildConfiguration(buildConfigurationImpl: BuildConfigurationImpl): BuildConfiguration 16 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.di 2 | 3 | import dagger.Binds 4 | import dagger.Module 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | import dagger.multibindings.IntoSet 8 | import gq.kirmanak.mealient.data.auth.AuthDataSource 9 | import gq.kirmanak.mealient.data.auth.AuthRepo 10 | import gq.kirmanak.mealient.data.auth.AuthStorage 11 | import gq.kirmanak.mealient.data.auth.impl.AuthDataSourceImpl 12 | import gq.kirmanak.mealient.data.auth.impl.AuthRepoImpl 13 | import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl 14 | import gq.kirmanak.mealient.data.auth.impl.CredentialsLogRedactor 15 | import gq.kirmanak.mealient.datasource.AuthenticationProvider 16 | import gq.kirmanak.mealient.logging.LogRedactor 17 | import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsAuthRepo 18 | 19 | @Module 20 | @InstallIn(SingletonComponent::class) 21 | interface AuthModule { 22 | 23 | @Binds 24 | fun bindAuthDataSource(authDataSourceImpl: AuthDataSourceImpl): AuthDataSource 25 | 26 | @Binds 27 | fun bindAuthRepo(authRepo: AuthRepoImpl): AuthRepo 28 | 29 | @Binds 30 | fun bindAuthProvider(authRepo: AuthRepoImpl): AuthenticationProvider 31 | 32 | @Binds 33 | fun bindAuthStorage(authStorageImpl: AuthStorageImpl): AuthStorage 34 | 35 | @Binds 36 | fun bindShoppingListsAuthRepo(impl: AuthRepoImpl): ShoppingListsAuthRepo 37 | 38 | @Binds 39 | @IntoSet 40 | fun bindCredentialsLogRedactor(impl: CredentialsLogRedactor): LogRedactor 41 | } 42 | -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/di/BaseURLModule.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.di 2 | 3 | import dagger.Binds 4 | import dagger.Module 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | import dagger.multibindings.IntoSet 8 | import gq.kirmanak.mealient.data.baseurl.* 9 | import gq.kirmanak.mealient.data.baseurl.impl.BaseUrlLogRedactor 10 | import gq.kirmanak.mealient.data.baseurl.impl.ServerInfoStorageImpl 11 | import gq.kirmanak.mealient.datasource.ServerUrlProvider 12 | import gq.kirmanak.mealient.logging.LogRedactor 13 | 14 | @Module 15 | @InstallIn(SingletonComponent::class) 16 | interface BaseURLModule { 17 | 18 | @Binds 19 | fun bindVersionDataSource(versionDataSourceImpl: VersionDataSourceImpl): VersionDataSource 20 | 21 | @Binds 22 | fun bindBaseUrlStorage(baseURLStorageImpl: ServerInfoStorageImpl): ServerInfoStorage 23 | 24 | @Binds 25 | fun bindServerInfoRepo(serverInfoRepoImpl: ServerInfoRepoImpl): ServerInfoRepo 26 | 27 | @Binds 28 | fun bindServerUrlProvider(serverInfoRepoImpl: ServerInfoRepoImpl): ServerUrlProvider 29 | 30 | @Binds 31 | @IntoSet 32 | fun bindBaseUrlLogRedactor(impl: BaseUrlLogRedactor): LogRedactor 33 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/di/DisclaimerModule.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.di 2 | 3 | import dagger.Binds 4 | import dagger.Module 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage 8 | import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorageImpl 9 | 10 | @Module 11 | @InstallIn(SingletonComponent::class) 12 | interface DisclaimerModule { 13 | 14 | @Binds 15 | fun provideDisclaimerStorage(disclaimerStorageImpl: DisclaimerStorageImpl): DisclaimerStorage 16 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/di/MigrationModule.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.di 2 | 3 | import dagger.Binds 4 | import dagger.Module 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | import dagger.multibindings.IntoSet 8 | import gq.kirmanak.mealient.data.migration.From24AuthMigrationExecutor 9 | import gq.kirmanak.mealient.data.migration.From30MigrationExecutor 10 | import gq.kirmanak.mealient.data.migration.MigrationDetector 11 | import gq.kirmanak.mealient.data.migration.MigrationDetectorImpl 12 | import gq.kirmanak.mealient.data.migration.MigrationExecutor 13 | 14 | @Module 15 | @InstallIn(SingletonComponent::class) 16 | interface MigrationModule { 17 | 18 | @Binds 19 | @IntoSet 20 | fun bindFrom24AuthMigrationExecutor(from24AuthMigrationExecutor: From24AuthMigrationExecutor): MigrationExecutor 21 | 22 | @Binds 23 | @IntoSet 24 | fun bindFrom30MigrationExecutor(impl: From30MigrationExecutor): MigrationExecutor 25 | 26 | @Binds 27 | fun bindMigrationDetector(migrationDetectorImpl: MigrationDetectorImpl): MigrationDetector 28 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/di/RecipeModule.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.di 2 | 3 | import dagger.Binds 4 | import dagger.Module 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | import gq.kirmanak.mealient.data.network.MealieDataSourceWrapper 8 | import gq.kirmanak.mealient.data.recipes.RecipeRepo 9 | import gq.kirmanak.mealient.data.recipes.impl.* 10 | import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource 11 | 12 | @Module 13 | @InstallIn(SingletonComponent::class) 14 | interface RecipeModule { 15 | 16 | @Binds 17 | fun provideRecipeDataSource(mealieDataSourceWrapper: MealieDataSourceWrapper): RecipeDataSource 18 | 19 | @Binds 20 | fun provideRecipeRepo(recipeRepoImpl: RecipeRepoImpl): RecipeRepo 21 | 22 | @Binds 23 | fun bindImageUrlProvider(recipeImageUrlProviderImpl: RecipeImageUrlProviderImpl): RecipeImageUrlProvider 24 | 25 | @Binds 26 | fun bindRecipePagingSourceFactory(recipePagingSourceFactoryImpl: RecipePagingSourceFactoryImpl): RecipePagingSourceFactory 27 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/di/ShareRecipeModule.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.di 2 | 3 | import dagger.Binds 4 | import dagger.Module 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | import gq.kirmanak.mealient.data.network.MealieDataSourceWrapper 8 | import gq.kirmanak.mealient.data.share.ParseRecipeDataSource 9 | import gq.kirmanak.mealient.data.share.ShareRecipeRepo 10 | import gq.kirmanak.mealient.data.share.ShareRecipeRepoImpl 11 | 12 | @Module 13 | @InstallIn(SingletonComponent::class) 14 | interface ShareRecipeModule { 15 | 16 | @Binds 17 | fun bindShareRecipeRepo(shareRecipeRepoImpl: ShareRecipeRepoImpl): ShareRecipeRepo 18 | 19 | @Binds 20 | fun bindParseRecipeDataSource(mealieDataSourceWrapper: MealieDataSourceWrapper): ParseRecipeDataSource 21 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/extensions/ContextExtensions.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.extensions 2 | 3 | import android.app.Activity 4 | import android.content.Context 5 | import android.content.ContextWrapper 6 | 7 | fun Context.findActivity(): Activity? { 8 | var context = this 9 | while (context is ContextWrapper) { 10 | if (context is Activity) return context 11 | context = context.baseContext 12 | } 13 | return null 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/extensions/ViewExtensions.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.extensions 2 | 3 | import android.content.Context 4 | import android.content.SharedPreferences 5 | import android.content.res.Configuration.UI_MODE_NIGHT_MASK 6 | import android.content.res.Configuration.UI_MODE_NIGHT_YES 7 | import android.os.Build 8 | import android.widget.Toast 9 | import androidx.annotation.StringRes 10 | import gq.kirmanak.mealient.logging.Logger 11 | import kotlinx.coroutines.channels.ChannelResult 12 | import kotlinx.coroutines.channels.awaitClose 13 | import kotlinx.coroutines.channels.onClosed 14 | import kotlinx.coroutines.channels.onFailure 15 | import kotlinx.coroutines.flow.Flow 16 | import kotlinx.coroutines.flow.callbackFlow 17 | 18 | fun ChannelResult.logErrors(methodName: String, logger: Logger): ChannelResult { 19 | onFailure { logger.e(it) { "$methodName: can't send event" } } 20 | onClosed { logger.e(it) { "$methodName: flow has been closed" } } 21 | return this 22 | } 23 | 24 | fun SharedPreferences.prefsChangeFlow( 25 | logger: Logger, 26 | valueReader: SharedPreferences.() -> T, 27 | ): Flow = callbackFlow { 28 | fun sendValue() = trySend(valueReader()).logErrors("prefsChangeFlow", logger) 29 | val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, _ -> sendValue() } 30 | sendValue() 31 | registerOnSharedPreferenceChangeListener(listener) 32 | awaitClose { unregisterOnSharedPreferenceChangeListener(listener) } 33 | } 34 | 35 | fun Context.showLongToast(text: String) = showToast(text, Toast.LENGTH_LONG) 36 | 37 | fun Context.showLongToast(@StringRes text: Int) = showLongToast(getString(text)) 38 | 39 | private fun Context.showToast(text: String, length: Int) { 40 | Toast.makeText(this, text, length).show() 41 | } 42 | 43 | fun Context.isDarkThemeOn(): Boolean { 44 | return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) resources.configuration.isNightModeActive 45 | else resources.configuration.uiMode and UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES 46 | } 47 | 48 | -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.ui.activity 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.activity.viewModels 7 | import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen 8 | import androidx.core.view.WindowInsetsControllerCompat 9 | import dagger.hilt.android.AndroidEntryPoint 10 | import gq.kirmanak.mealient.extensions.isDarkThemeOn 11 | import gq.kirmanak.mealient.logging.Logger 12 | import gq.kirmanak.mealient.ui.AppTheme 13 | import javax.inject.Inject 14 | 15 | @AndroidEntryPoint 16 | class MainActivity : ComponentActivity() { 17 | 18 | @Inject 19 | lateinit var logger: Logger 20 | 21 | private val viewModel by viewModels() 22 | 23 | override fun onCreate(savedInstanceState: Bundle?) { 24 | val splashScreen = installSplashScreen() 25 | super.onCreate(savedInstanceState) 26 | logger.v { "onCreate() called with: savedInstanceState = $savedInstanceState" } 27 | with(WindowInsetsControllerCompat(window, window.decorView)) { 28 | val isAppearanceLightBars = !isDarkThemeOn() 29 | isAppearanceLightNavigationBars = isAppearanceLightBars 30 | isAppearanceLightStatusBars = isAppearanceLightBars 31 | } 32 | splashScreen.setKeepOnScreenCondition { 33 | viewModel.appState.value.forcedRoute == ForcedDestination.Undefined 34 | } 35 | setContent { 36 | AppTheme { 37 | MealientApp(viewModel) 38 | } 39 | } 40 | } 41 | 42 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/ui/add/AddRecipeScreenEvent.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.ui.add 2 | 3 | internal sealed interface AddRecipeScreenEvent { 4 | 5 | data class RecipeNameInput( 6 | val input: String, 7 | ) : AddRecipeScreenEvent 8 | 9 | data class RecipeDescriptionInput( 10 | val input: String, 11 | ) : AddRecipeScreenEvent 12 | 13 | data class RecipeYieldInput( 14 | val input: String, 15 | ) : AddRecipeScreenEvent 16 | 17 | data object PublicRecipeToggle : AddRecipeScreenEvent 18 | 19 | data object DisableCommentsToggle : AddRecipeScreenEvent 20 | 21 | data object AddIngredientClick : AddRecipeScreenEvent 22 | 23 | data object AddInstructionClick : AddRecipeScreenEvent 24 | 25 | data object SaveRecipeClick : AddRecipeScreenEvent 26 | 27 | data class IngredientInput( 28 | val ingredientIndex: Int, 29 | val input: String, 30 | ) : AddRecipeScreenEvent 31 | 32 | data class InstructionInput( 33 | val instructionIndex: Int, 34 | val input: String, 35 | ) : AddRecipeScreenEvent 36 | 37 | data object ClearInputClick : AddRecipeScreenEvent 38 | 39 | data object SnackbarShown : AddRecipeScreenEvent 40 | 41 | data class RemoveIngredientClick( 42 | val ingredientIndex: Int, 43 | ) : AddRecipeScreenEvent 44 | 45 | data class RemoveInstructionClick( 46 | val instructionIndex: Int, 47 | ) : AddRecipeScreenEvent 48 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/ui/add/AddRecipeScreenState.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.ui.add 2 | 3 | internal data class AddRecipeScreenState( 4 | val snackbarMessage: AddRecipeSnackbarMessage? = null, 5 | val isLoading: Boolean = false, 6 | val recipeNameInput: String = "", 7 | val recipeDescriptionInput: String = "", 8 | val recipeYieldInput: String = "", 9 | val isPublicRecipe: Boolean = false, 10 | val disableComments: Boolean = false, 11 | val saveButtonEnabled: Boolean = false, 12 | val clearButtonEnabled: Boolean = true, 13 | val ingredients: List = emptyList(), 14 | val instructions: List = emptyList(), 15 | ) -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/ui/add/AddRecipeSnackbarMessage.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.ui.add 2 | 3 | internal sealed interface AddRecipeSnackbarMessage { 4 | 5 | data object Success : AddRecipeSnackbarMessage 6 | 7 | data object Error : AddRecipeSnackbarMessage 8 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationScreenEvent.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.ui.auth 2 | 3 | internal sealed interface AuthenticationScreenEvent { 4 | 5 | data class OnEmailInput(val input: String) : AuthenticationScreenEvent 6 | 7 | data class OnPasswordInput(val input: String) : AuthenticationScreenEvent 8 | 9 | data object OnLoginClick : AuthenticationScreenEvent 10 | 11 | data object TogglePasswordVisibility : AuthenticationScreenEvent 12 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationScreenState.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.ui.auth 2 | 3 | internal data class AuthenticationScreenState( 4 | val isLoading: Boolean = false, 5 | val isSuccessful: Boolean = false, 6 | val errorText: String? = null, 7 | val emailInput: String = "", 8 | val passwordInput: String = "", 9 | val buttonEnabled: Boolean = false, 10 | val isPasswordVisible: Boolean = false, 11 | ) -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/ui/auth/Extensions.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.ui.auth 2 | 3 | import androidx.compose.ui.ExperimentalComposeUiApi 4 | import androidx.compose.ui.Modifier 5 | import androidx.compose.ui.autofill.AutofillNode 6 | import androidx.compose.ui.autofill.AutofillType 7 | import androidx.compose.ui.composed 8 | import androidx.compose.ui.focus.onFocusChanged 9 | import androidx.compose.ui.layout.boundsInWindow 10 | import androidx.compose.ui.layout.onGloballyPositioned 11 | import androidx.compose.ui.platform.LocalAutofill 12 | import androidx.compose.ui.platform.LocalAutofillTree 13 | 14 | @OptIn(ExperimentalComposeUiApi::class) 15 | fun Modifier.autofill( 16 | autofillTypes: List, 17 | onFill: ((String) -> Unit), 18 | ) = composed { 19 | val autofillNode = AutofillNode( 20 | autofillTypes = autofillTypes, 21 | onFill = onFill, 22 | ) 23 | LocalAutofillTree.current += autofillNode 24 | 25 | val autofill = LocalAutofill.current 26 | onGloballyPositioned { 27 | autofillNode.boundingBox = it.boundsInWindow() 28 | }.onFocusChanged { focusState -> 29 | autofill?.run { 30 | if (focusState.isFocused) { 31 | requestAutofillForNode(autofillNode) 32 | } else { 33 | cancelAutofillForNode(autofillNode) 34 | } 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLScreenEvent.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.ui.baseurl 2 | 3 | import java.security.cert.X509Certificate 4 | 5 | internal sealed interface BaseURLScreenEvent { 6 | 7 | data object OnProceedClick : BaseURLScreenEvent 8 | 9 | data class OnUserInput(val input: String) : BaseURLScreenEvent 10 | 11 | data object OnInvalidCertificateDialogDismiss : BaseURLScreenEvent 12 | 13 | data class OnInvalidCertificateDialogAccept( 14 | val certificate: X509Certificate, 15 | ) : BaseURLScreenEvent 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLScreenState.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.ui.baseurl 2 | 3 | internal data class BaseURLScreenState( 4 | val isConfigured: Boolean = false, 5 | val userInput: String = "", 6 | val errorText: String? = null, 7 | val isButtonEnabled: Boolean = false, 8 | val isLoading: Boolean = false, 9 | val invalidCertificateDialogState: InvalidCertificateDialogState? = null, 10 | val isNavigationEnabled: Boolean = true, 11 | ) { 12 | 13 | data class InvalidCertificateDialogState( 14 | val message: String, 15 | val onAcceptEvent: BaseURLScreenEvent, 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/ui/baseurl/InvalidCertificateDialog.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.ui.baseurl 2 | 3 | import androidx.compose.material3.AlertDialog 4 | import androidx.compose.material3.Text 5 | import androidx.compose.material3.TextButton 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.res.stringResource 8 | import gq.kirmanak.mealient.R 9 | import gq.kirmanak.mealient.ui.AppTheme 10 | import gq.kirmanak.mealient.ui.preview.ColorSchemePreview 11 | 12 | @Composable 13 | internal fun InvalidCertificateDialog( 14 | state: BaseURLScreenState.InvalidCertificateDialogState, 15 | onEvent: (BaseURLScreenEvent) -> Unit, 16 | ) { 17 | val onDismiss = { 18 | onEvent(BaseURLScreenEvent.OnInvalidCertificateDialogDismiss) 19 | } 20 | AlertDialog( 21 | onDismissRequest = onDismiss, 22 | confirmButton = { 23 | TextButton( 24 | onClick = { onEvent(state.onAcceptEvent) }, 25 | ) { 26 | Text(text = stringResource(id = R.string.fragment_base_url_invalid_certificate_accept)) 27 | } 28 | }, 29 | dismissButton = { 30 | TextButton( 31 | onClick = onDismiss, 32 | ) { 33 | Text(text = stringResource(id = R.string.fragment_base_url_invalid_certificate_deny)) 34 | } 35 | }, 36 | title = { 37 | Text(text = stringResource(id = R.string.fragment_base_url_invalid_certificate_title)) 38 | }, 39 | text = { 40 | Text(text = state.message) 41 | }, 42 | ) 43 | } 44 | 45 | @ColorSchemePreview 46 | @Composable 47 | private fun InvalidCertificateDialogPreview() { 48 | AppTheme { 49 | InvalidCertificateDialog( 50 | state = BaseURLScreenState.InvalidCertificateDialogState( 51 | message = "This is a preview message", 52 | onAcceptEvent = BaseURLScreenEvent.OnInvalidCertificateDialogDismiss, 53 | ), 54 | onEvent = {}, 55 | ) 56 | } 57 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerScreenState.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.ui.disclaimer 2 | 3 | internal data class DisclaimerScreenState( 4 | val isCountDownOver: Boolean, 5 | val countDown: Int, 6 | ) 7 | -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/KeepScreenOn.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.ui.recipes.info 2 | 3 | import android.view.WindowManager 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.DisposableEffect 6 | import androidx.compose.ui.platform.LocalContext 7 | import gq.kirmanak.mealient.extensions.findActivity 8 | 9 | @Composable 10 | fun KeepScreenOn() { 11 | val context = LocalContext.current 12 | val window = context.findActivity()?.window ?: return 13 | DisposableEffect(Unit) { 14 | window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) 15 | onDispose { 16 | window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoUiState.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.ui.recipes.info 2 | 3 | import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientEntity 4 | import gq.kirmanak.mealient.database.recipe.entity.RecipeInstructionEntity 5 | import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity 6 | 7 | data class RecipeInfoUiState( 8 | val showIngredients: Boolean = false, 9 | val showInstructions: Boolean = false, 10 | val summaryEntity: RecipeSummaryEntity? = null, 11 | val recipeIngredients: List = emptyList(), 12 | val recipeInstructions: Map> = emptyMap(), 13 | val title: String? = null, 14 | val description: String? = null, 15 | val disableAmounts: Boolean = true, 16 | val imageUrl: String? = null, 17 | ) 18 | -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/ConfirmDeleteDialog.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.ui.recipes.list 2 | 3 | import androidx.compose.material3.AlertDialog 4 | import androidx.compose.material3.Text 5 | import androidx.compose.material3.TextButton 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.res.stringResource 9 | import gq.kirmanak.mealient.R 10 | 11 | @Composable 12 | internal fun ConfirmDeleteDialog( 13 | onDismissRequest: () -> Unit, 14 | onConfirm: (RecipeListItemState) -> Unit, 15 | item: RecipeListItemState, 16 | modifier: Modifier = Modifier, 17 | ) { 18 | AlertDialog( 19 | modifier = modifier, 20 | onDismissRequest = onDismissRequest, 21 | confirmButton = { 22 | TextButton( 23 | onClick = { 24 | onConfirm(item) 25 | }, 26 | ) { 27 | Text( 28 | text = stringResource(id = R.string.fragment_recipes_delete_recipe_confirm_dialog_positive_btn), 29 | ) 30 | } 31 | }, 32 | dismissButton = { 33 | TextButton( 34 | onClick = onDismissRequest, 35 | ) { 36 | Text( 37 | text = stringResource(id = R.string.fragment_recipes_delete_recipe_confirm_dialog_negative_btn), 38 | ) 39 | } 40 | }, 41 | title = { 42 | Text( 43 | text = stringResource(id = R.string.fragment_recipes_delete_recipe_confirm_dialog_title), 44 | ) 45 | }, 46 | text = { 47 | Text( 48 | text = stringResource( 49 | id = R.string.fragment_recipes_delete_recipe_confirm_dialog_message, 50 | item.entity.name 51 | ), 52 | ) 53 | }, 54 | ) 55 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipeListItemState.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.ui.recipes.list 2 | 3 | import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity 4 | 5 | data class RecipeListItemState( 6 | val imageUrl: String?, 7 | val showFavoriteIcon: Boolean, 8 | val entity: RecipeSummaryEntity, 9 | ) 10 | -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipeListSnackbar.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.ui.recipes.list 2 | 3 | internal sealed interface RecipeListSnackbar { 4 | 5 | data class FavoriteAdded(val name: String) : RecipeListSnackbar 6 | 7 | data class FavoriteRemoved(val name: String) : RecipeListSnackbar 8 | 9 | data object FavoriteUpdateFailed : RecipeListSnackbar 10 | 11 | data object DeleteFailed : RecipeListSnackbar 12 | } -------------------------------------------------------------------------------- /app/src/main/java/gq/kirmanak/mealient/ui/share/ShareRecipeViewModel.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.ui.share 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.lifecycle.MutableLiveData 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.viewModelScope 7 | import dagger.hilt.android.lifecycle.HiltViewModel 8 | import gq.kirmanak.mealient.data.share.ShareRecipeRepo 9 | import gq.kirmanak.mealient.datasource.runCatchingExceptCancel 10 | import gq.kirmanak.mealient.logging.Logger 11 | import gq.kirmanak.mealient.ui.OperationUiState 12 | import kotlinx.coroutines.launch 13 | import javax.inject.Inject 14 | 15 | @HiltViewModel 16 | class ShareRecipeViewModel @Inject constructor( 17 | private val shareRecipeRepo: ShareRecipeRepo, 18 | private val logger: Logger, 19 | ) : ViewModel() { 20 | 21 | private val _saveResult = MutableLiveData>(OperationUiState.Initial()) 22 | val saveResult: LiveData> get() = _saveResult 23 | 24 | fun saveRecipeByURL(url: CharSequence) { 25 | logger.v { "saveRecipeByURL() called with: url = $url" } 26 | _saveResult.postValue(OperationUiState.Progress()) 27 | viewModelScope.launch { 28 | val result = runCatchingExceptCancel { shareRecipeRepo.saveRecipeByURL(url) } 29 | .onSuccess { logger.d { "Successfully saved recipe by URL" } } 30 | .onFailure { logger.e(it) { "Can't save recipe by URL" } } 31 | _saveResult.postValue(OperationUiState.fromResult(result)) 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-night/ic_splash_screen_background.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_monochrome.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_splash_screen.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_splash_screen_background.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/placeholder_recipe.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 13 | 16 | 19 | 20 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/values-de/plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Okay (%d Sekunde) 5 | Okay (%d Sekunden) 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/values-es/plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Bien, (%d segundo) 5 | Bien, (%d segundos) 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/values-fr/plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Ok (%d seconde) 5 | Ok (%d secondes) 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/values-nl/plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Oké (%d seconde) 5 | Oké (%d seconden) 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/values-pt/plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Ok (%d segundo) 5 | Ok (%d segundos) 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/values-ru/plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Хорошо (%d секунда) 5 | Хорошо (%d секунды) 6 | Хорошо (%d секунд) 7 | Хорошо (%d секунд) 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/values-v31/drawable.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | @drawable/ic_progress_bar 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/values/plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Okay (%d second) 5 | Okay (%d seconds) 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | 16 | 17 | -------------------------------------------------------------------------------- /app/src/main/res/xml/data_extraction_rules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /app/src/main/res/xml/file_provider_paths.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/xml/full_backup_rules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/xml/locales_config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/xml/network_security_config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/src/test/java/gq/kirmanak/mealient/data/disclaimer/DisclaimerStorageImplTest.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.disclaimer 2 | 3 | import com.google.common.truth.Truth.assertThat 4 | import dagger.hilt.android.testing.HiltAndroidTest 5 | import gq.kirmanak.mealient.test.HiltRobolectricTest 6 | import kotlinx.coroutines.test.runTest 7 | import org.junit.Test 8 | import javax.inject.Inject 9 | 10 | @HiltAndroidTest 11 | class DisclaimerStorageImplTest : HiltRobolectricTest() { 12 | 13 | @Inject 14 | lateinit var subject: DisclaimerStorageImpl 15 | 16 | @Test 17 | fun `when isDisclaimerAccepted initially then false`() = runTest { 18 | assertThat(subject.isDisclaimerAccepted()).isFalse() 19 | } 20 | 21 | @Test 22 | fun `when isDisclaimerAccepted after accept then true`() = runTest { 23 | subject.acceptDisclaimer() 24 | assertThat(subject.isDisclaimerAccepted()).isTrue() 25 | } 26 | 27 | } -------------------------------------------------------------------------------- /app/src/test/java/gq/kirmanak/mealient/data/storage/PreferencesStorageImplTest.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.data.storage 2 | 3 | import com.google.common.truth.Truth.assertThat 4 | import dagger.hilt.android.testing.HiltAndroidTest 5 | import gq.kirmanak.mealient.test.HiltRobolectricTest 6 | import kotlinx.coroutines.flow.first 7 | import kotlinx.coroutines.test.runTest 8 | import org.junit.Test 9 | import javax.inject.Inject 10 | 11 | @HiltAndroidTest 12 | class PreferencesStorageImplTest : HiltRobolectricTest() { 13 | 14 | @Inject 15 | lateinit var subject: PreferencesStorage 16 | 17 | @Test 18 | fun `when getValue without writes then null`() = runTest { 19 | assertThat(subject.getValue(subject.baseUrlKey)).isNull() 20 | } 21 | 22 | @Test(expected = IllegalStateException::class) 23 | fun `when requireValue without writes then throws IllegalStateException`() = runTest { 24 | subject.requireValue(subject.baseUrlKey) 25 | } 26 | 27 | @Test 28 | fun `when getValue after write then returns value`() = runTest { 29 | subject.storeValues(Pair(subject.baseUrlKey, "test")) 30 | assertThat(subject.getValue(subject.baseUrlKey)).isEqualTo("test") 31 | } 32 | 33 | @Test 34 | fun `when storeValue then valueUpdates emits`() = runTest { 35 | subject.storeValues(Pair(subject.baseUrlKey, "test")) 36 | assertThat(subject.valueUpdates(subject.baseUrlKey).first()).isEqualTo("test") 37 | } 38 | 39 | @Test 40 | fun `when remove value then getValue returns null`() = runTest { 41 | subject.storeValues(Pair(subject.baseUrlKey, "test")) 42 | subject.removeValues(subject.baseUrlKey) 43 | assertThat(subject.getValue(subject.baseUrlKey)).isNull() 44 | } 45 | } -------------------------------------------------------------------------------- /app/src/test/java/gq/kirmanak/mealient/test/AuthImplTestData.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.test 2 | 3 | import gq.kirmanak.mealient.datasource.models.GetUserInfoResponse 4 | import gq.kirmanak.mealient.datasource.models.VersionResponse 5 | 6 | object AuthImplTestData { 7 | const val TEST_USERNAME = "TEST_USERNAME" 8 | const val TEST_PASSWORD = "TEST_PASSWORD" 9 | const val TEST_BASE_URL = "https://example.com" 10 | const val TEST_TOKEN = "TEST_TOKEN" 11 | const val TEST_API_TOKEN = "TEST_API_TOKEN" 12 | 13 | val FAVORITE_RECIPES_LIST = listOf("cake", "porridge") 14 | val USER_INFO = GetUserInfoResponse("userId", FAVORITE_RECIPES_LIST) 15 | val VERSION_RESPONSE = VersionResponse("1.0.0") 16 | } -------------------------------------------------------------------------------- /app/src/test/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.ui.disclaimer 2 | 3 | import com.google.common.truth.Truth.assertThat 4 | import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage 5 | import gq.kirmanak.mealient.test.BaseUnitTest 6 | import io.mockk.impl.annotations.MockK 7 | import kotlinx.coroutines.ExperimentalCoroutinesApi 8 | import kotlinx.coroutines.flow.take 9 | import kotlinx.coroutines.test.currentTime 10 | import kotlinx.coroutines.test.runTest 11 | import org.junit.Before 12 | import org.junit.Test 13 | import java.util.concurrent.TimeUnit 14 | 15 | @OptIn(ExperimentalCoroutinesApi::class) 16 | internal class DisclaimerViewModelTest : BaseUnitTest() { 17 | 18 | @MockK(relaxUnitFun = true) 19 | lateinit var storage: DisclaimerStorage 20 | 21 | lateinit var subject: DisclaimerViewModel 22 | 23 | @Before 24 | override fun setUp() { 25 | super.setUp() 26 | subject = DisclaimerViewModel(storage, logger) 27 | } 28 | 29 | @Test 30 | fun `when tickerFlow 3 seconds then sends count every 3 seconds`() = runTest { 31 | subject.tickerFlow(3, TimeUnit.SECONDS).take(10).collect { 32 | assertThat(it * 3000).isEqualTo(currentTime) 33 | } 34 | } 35 | 36 | @Test 37 | fun `when tickerFlow 500 ms then sends count every 500 ms`() = runTest { 38 | subject.tickerFlow(500, TimeUnit.MILLISECONDS).take(10).collect { 39 | assertThat(it * 500).isEqualTo(currentTime) 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /architecture/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /architecture/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("gq.kirmanak.mealient.library") 3 | id("dagger.hilt.android.plugin") 4 | alias(libs.plugins.ksp) 5 | } 6 | 7 | android { 8 | namespace = "gq.kirmanak.mealient.architecture" 9 | } 10 | 11 | dependencies { 12 | implementation(libs.google.dagger.hiltAndroid) 13 | ksp(libs.google.dagger.hiltCompiler) 14 | 15 | testImplementation(libs.jetbrains.kotlinx.coroutinesTest) 16 | testImplementation(libs.androidx.test.junit) 17 | testImplementation(libs.androidx.coreTesting) 18 | testImplementation(libs.google.truth) 19 | testImplementation(project(":testing")) 20 | } -------------------------------------------------------------------------------- /architecture/src/main/kotlin/gq/kirmanak/mealient/architecture/FlowExtensions.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.architecture 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import kotlinx.coroutines.flow.FlowCollector 5 | 6 | fun Flow.valueUpdatesOnly(): Flow = when (this) { 7 | is ValueUpdateOnlyFlowImpl -> this 8 | else -> ValueUpdateOnlyFlowImpl(this) 9 | } 10 | 11 | private class ValueUpdateOnlyFlowImpl(private val upstream: Flow) : Flow { 12 | 13 | override suspend fun collect(collector: FlowCollector) { 14 | var previousValue: T? = null 15 | upstream.collect { value -> 16 | if (previousValue != null && previousValue != value) { 17 | collector.emit(value) 18 | } 19 | previousValue = value 20 | } 21 | } 22 | 23 | } -------------------------------------------------------------------------------- /architecture/src/main/kotlin/gq/kirmanak/mealient/architecture/configuration/AppDispatchers.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.architecture.configuration 2 | 3 | import kotlinx.coroutines.CoroutineDispatcher 4 | 5 | interface AppDispatchers { 6 | val io: CoroutineDispatcher 7 | val main: CoroutineDispatcher 8 | val default: CoroutineDispatcher 9 | val unconfined: CoroutineDispatcher 10 | } -------------------------------------------------------------------------------- /architecture/src/main/kotlin/gq/kirmanak/mealient/architecture/configuration/AppDispatchersImpl.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.architecture.configuration 2 | 3 | import kotlinx.coroutines.CoroutineDispatcher 4 | import kotlinx.coroutines.Dispatchers 5 | import javax.inject.Inject 6 | 7 | class AppDispatchersImpl @Inject constructor() : AppDispatchers { 8 | 9 | override val io: CoroutineDispatcher get() = Dispatchers.IO 10 | 11 | override val main: CoroutineDispatcher get() = Dispatchers.Main 12 | 13 | override val default: CoroutineDispatcher get() = Dispatchers.Default 14 | 15 | override val unconfined: CoroutineDispatcher get() = Dispatchers.Unconfined 16 | } -------------------------------------------------------------------------------- /architecture/src/main/kotlin/gq/kirmanak/mealient/architecture/configuration/ArchitectureModule.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.architecture.configuration 2 | 3 | import dagger.Binds 4 | import dagger.Module 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | 8 | @Module 9 | @InstallIn(SingletonComponent::class) 10 | interface ArchitectureModule { 11 | 12 | @Binds 13 | fun bindAppDispatchers(appDispatchersImpl: AppDispatchersImpl): AppDispatchers 14 | } -------------------------------------------------------------------------------- /architecture/src/main/kotlin/gq/kirmanak/mealient/architecture/configuration/BuildConfiguration.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.architecture.configuration 2 | 3 | interface BuildConfiguration { 4 | 5 | fun isDebug(): Boolean 6 | 7 | fun versionCode(): Int 8 | } -------------------------------------------------------------------------------- /architecture/src/test/kotlin/gq/kirmanak/mealient/architecture/FlowExtensionsKtTest.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.architecture 2 | 3 | import com.google.common.truth.Truth.assertThat 4 | import gq.kirmanak.mealient.test.BaseUnitTest 5 | import kotlinx.coroutines.flow.flowOf 6 | import kotlinx.coroutines.flow.toList 7 | import kotlinx.coroutines.test.runTest 8 | import org.junit.Test 9 | 10 | class FlowExtensionsKtTest : BaseUnitTest() { 11 | 12 | @Test 13 | fun `when flow has an update then valueUpdatesOnly sends updated value`() = runTest { 14 | val flow = flowOf(1, 2) 15 | assertThat(flow.valueUpdatesOnly().toList()).isEqualTo(listOf(2)) 16 | } 17 | 18 | @Test 19 | fun `when flow has repeated values then valueUpdatesOnly sends updated value`() = runTest { 20 | val flow = flowOf(1, 1, 1, 2) 21 | assertThat(flow.valueUpdatesOnly().toList()).isEqualTo(listOf(2)) 22 | } 23 | 24 | @Test 25 | fun `when flow has one value then valueUpdatesOnly is empty`() = runTest { 26 | val flow = flowOf(1) 27 | assertThat(flow.valueUpdatesOnly().toList()).isEmpty() 28 | } 29 | 30 | @Test 31 | fun `when flow has two updates then valueUpdatesOnly sends both`() = runTest { 32 | val flow = flowOf(1, 2, 1) 33 | assertThat(flow.valueUpdatesOnly().toList()).isEqualTo(listOf(2, 1)) 34 | } 35 | 36 | @Test 37 | fun `when flow has three updates then valueUpdatesOnly sends all`() = runTest { 38 | val flow = flowOf(1, 2, 1, 3) 39 | assertThat(flow.valueUpdatesOnly().toList()).isEqualTo(listOf(2, 1, 3)) 40 | } 41 | } -------------------------------------------------------------------------------- /build-logic/convention/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | 3 | -------------------------------------------------------------------------------- /build-logic/convention/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `kotlin-dsl` 3 | } 4 | 5 | group = "gq.kirmanak.mealient.buildlogic" 6 | 7 | dependencies { 8 | implementation(libs.jetbrains.kotlinPlugin) 9 | implementation(libs.android.gradlePlugin) 10 | } 11 | 12 | gradlePlugin { 13 | plugins { 14 | register("androidApplication") { 15 | id = "gq.kirmanak.mealient.application" 16 | implementationClass = "AndroidApplicationConventionPlugin" 17 | } 18 | register("androidLibrary") { 19 | id = "gq.kirmanak.mealient.library" 20 | implementationClass = "AndroidLibraryConventionPlugin" 21 | } 22 | register("compose") { 23 | id = "gq.kirmanak.mealient.compose" 24 | implementationClass = "AndroidLibraryComposeConventionPlugin" 25 | } 26 | register("appCompose") { 27 | id = "gq.kirmanak.mealient.compose.app" 28 | implementationClass = "AndroidApplicationComposeConventionPlugin" 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt: -------------------------------------------------------------------------------- 1 | import com.android.build.gradle.internal.dsl.BaseAppModuleExtension 2 | import gq.kirmanak.mealient.configureAndroidCompose 3 | import org.gradle.api.Plugin 4 | import org.gradle.api.Project 5 | import org.gradle.kotlin.dsl.configure 6 | 7 | class AndroidApplicationComposeConventionPlugin : Plugin { 8 | 9 | override fun apply(target: Project) { 10 | with(target) { 11 | pluginManager.apply("com.android.application") 12 | pluginManager.apply("org.jetbrains.kotlin.plugin.compose") 13 | 14 | extensions.configure { 15 | configureAndroidCompose(this) 16 | } 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt: -------------------------------------------------------------------------------- 1 | import com.android.build.gradle.internal.dsl.BaseAppModuleExtension 2 | import gq.kirmanak.mealient.Versions 3 | import gq.kirmanak.mealient.configureKotlinAndroid 4 | import org.gradle.api.Plugin 5 | import org.gradle.api.Project 6 | import org.gradle.kotlin.dsl.configure 7 | 8 | class AndroidApplicationConventionPlugin : Plugin { 9 | 10 | override fun apply(target: Project) { 11 | with(target) { 12 | with(pluginManager) { 13 | apply("com.android.application") 14 | apply("org.jetbrains.kotlin.android") 15 | apply("org.jetbrains.kotlinx.kover") 16 | } 17 | 18 | extensions.configure { 19 | configureKotlinAndroid(this) 20 | defaultConfig.targetSdk = Versions.TARGET_SDK_VERSION 21 | } 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt: -------------------------------------------------------------------------------- 1 | import com.android.build.gradle.LibraryExtension 2 | import gq.kirmanak.mealient.configureAndroidCompose 3 | import org.gradle.api.Plugin 4 | import org.gradle.api.Project 5 | import org.gradle.kotlin.dsl.configure 6 | 7 | class AndroidLibraryComposeConventionPlugin : Plugin { 8 | 9 | override fun apply(target: Project) { 10 | with(target) { 11 | pluginManager.apply("org.jetbrains.kotlin.plugin.compose") 12 | 13 | extensions.configure { 14 | configureAndroidCompose(this) 15 | } 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt: -------------------------------------------------------------------------------- 1 | import com.android.build.gradle.LibraryExtension 2 | import gq.kirmanak.mealient.Versions 3 | import gq.kirmanak.mealient.configureKotlinAndroid 4 | import org.gradle.api.Plugin 5 | import org.gradle.api.Project 6 | import org.gradle.kotlin.dsl.configure 7 | 8 | class AndroidLibraryConventionPlugin : Plugin { 9 | override fun apply(target: Project) { 10 | with(target) { 11 | with(pluginManager) { 12 | apply("com.android.library") 13 | apply("org.jetbrains.kotlin.android") 14 | apply("org.jetbrains.kotlinx.kover") 15 | } 16 | 17 | extensions.configure { 18 | configureKotlinAndroid(this) 19 | defaultConfig.targetSdk = Versions.TARGET_SDK_VERSION 20 | } 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /build-logic/convention/src/main/kotlin/gq/kirmanak/mealient/Extensions.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient 2 | 3 | import org.gradle.api.Action 4 | import org.gradle.api.NamedDomainObjectContainer 5 | import org.gradle.api.Project 6 | import org.gradle.api.artifacts.MinimalExternalModuleDependency 7 | import org.gradle.api.plugins.ExtensionAware 8 | import org.gradle.api.provider.Provider 9 | import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension 10 | import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet 11 | 12 | internal val Project.kotlin: KotlinAndroidProjectExtension 13 | get() = (this as ExtensionAware).extensions.getByName("kotlin") as KotlinAndroidProjectExtension 14 | 15 | internal fun Project.kotlin(configure: Action): Unit = 16 | (this as ExtensionAware).extensions.configure("kotlin", configure) 17 | 18 | internal fun KotlinAndroidProjectExtension.sourceSets(configure: Action>): Unit = 19 | (this as ExtensionAware).extensions.configure("sourceSets", configure) 20 | 21 | internal fun Project.library(name: String): Provider { 22 | return libs.findLibrary(name).get() 23 | } 24 | 25 | -------------------------------------------------------------------------------- /build-logic/convention/src/main/kotlin/gq/kirmanak/mealient/Versions.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient 2 | 3 | import org.gradle.api.Project 4 | import org.gradle.api.artifacts.VersionCatalog 5 | import org.gradle.api.artifacts.VersionCatalogsExtension 6 | import org.gradle.kotlin.dsl.getByType 7 | 8 | object Versions { 9 | const val MIN_SDK_VERSION = 26 10 | const val TARGET_SDK_VERSION = 34 11 | const val COMPILE_SDK_VERSION = 34 12 | } 13 | 14 | val Project.libs: VersionCatalog 15 | get() = extensions.getByType().named("libs") 16 | -------------------------------------------------------------------------------- /build-logic/gradle.properties: -------------------------------------------------------------------------------- 1 | # Gradle properties are not passed to included builds https://github.com/gradle/gradle/issues/2534 2 | org.gradle.parallel=true 3 | org.gradle.caching=true 4 | org.gradle.configureondemand=true 5 | -------------------------------------------------------------------------------- /build-logic/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnstableApiUsage") 2 | 3 | dependencyResolutionManagement { 4 | repositories { 5 | google() 6 | mavenCentral() 7 | } 8 | versionCatalogs { 9 | create("libs") { 10 | from(files("../gradle/libs.versions.toml")) 11 | } 12 | } 13 | } 14 | 15 | include(":convention") 16 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | buildscript { 2 | 3 | repositories { 4 | google() 5 | mavenCentral() 6 | } 7 | 8 | dependencies { 9 | classpath(libs.android.gradlePlugin) 10 | classpath(libs.google.dagger.hiltPlugin) 11 | classpath(libs.jetbrains.kotlinPlugin) 12 | classpath(libs.jetbrains.serializationPlugin) 13 | } 14 | } 15 | 16 | plugins { 17 | alias(libs.plugins.sonarqube) 18 | alias(libs.plugins.ksp) apply false 19 | alias(libs.plugins.kover) apply false 20 | alias(libs.plugins.compose.compiler) apply false 21 | } 22 | 23 | sonarqube { 24 | properties { 25 | property("sonar.projectKey", "kirmanak_Mealient") 26 | property("sonar.organization", "kirmanak") 27 | property("sonar.host.url", "https://sonarcloud.io") 28 | property( 29 | "sonar.coverage.jacoco.xmlReportPaths", 30 | "${projectDir.path}/app/build/reports/kover/reportRelease.xml" 31 | ) 32 | } 33 | } 34 | 35 | subprojects { 36 | sonarqube { 37 | properties { 38 | property( 39 | "sonar.androidLint.reportPaths", 40 | "${projectDir.path}/build/reports/lint-results-debug.xml" 41 | ) 42 | 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /crowdin.yml: -------------------------------------------------------------------------------- 1 | "preserve_hierarchy": true 2 | 3 | files: [ 4 | { 5 | "source": "/app/src/main/res/values/strings.xml", 6 | "translation": "/app/src/main/res/values-%two_letters_code%/strings.xml" 7 | }, 8 | { 9 | "source": "/app/src/main/res/values/plurals.xml", 10 | "translation": "/app/src/main/res/values-%two_letters_code%/plurals.xml" 11 | }, 12 | { 13 | "source": "/features/shopping_lists/src/main/res/values/strings.xml", 14 | "translation": "/features/shopping_lists/src/main/res/values-%two_letters_code%/strings.xml" 15 | } 16 | ] 17 | -------------------------------------------------------------------------------- /database/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /database/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("gq.kirmanak.mealient.library") 3 | id("dagger.hilt.android.plugin") 4 | alias(libs.plugins.ksp) 5 | } 6 | 7 | android { 8 | namespace = "gq.kirmanak.mealient.database" 9 | } 10 | 11 | dependencies { 12 | implementation(project(":logging")) 13 | testImplementation(project(":testing")) 14 | testImplementation(project(":database_test")) 15 | 16 | implementation(libs.google.dagger.hiltAndroid) 17 | ksp(libs.google.dagger.hiltCompiler) 18 | kspTest(libs.google.dagger.hiltAndroidCompiler) 19 | testImplementation(libs.google.dagger.hiltAndroidTesting) 20 | 21 | implementation(libs.androidx.room.ktx) 22 | 23 | implementation(libs.androidx.room.runtime) 24 | implementation(libs.androidx.room.paging) 25 | ksp(libs.androidx.room.compiler) 26 | testImplementation(libs.androidx.room.testing) 27 | 28 | api(libs.jetbrains.kotlinx.datetime) 29 | 30 | implementation(libs.jetbrains.kotlinx.coroutinesAndroid) 31 | testImplementation(libs.jetbrains.kotlinx.coroutinesTest) 32 | 33 | testImplementation(libs.androidx.test.junit) 34 | 35 | testImplementation(libs.google.truth) 36 | 37 | testImplementation(libs.io.mockk) 38 | } -------------------------------------------------------------------------------- /database/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /database/src/main/kotlin/gq/kirmanak/mealient/database/AppDb.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.database 2 | 3 | import androidx.room.Database 4 | import androidx.room.RoomDatabase 5 | import androidx.room.TypeConverters 6 | import gq.kirmanak.mealient.database.recipe.RecipeDao 7 | import gq.kirmanak.mealient.database.recipe.entity.RecipeEntity 8 | import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientEntity 9 | import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientToInstructionEntity 10 | import gq.kirmanak.mealient.database.recipe.entity.RecipeInstructionEntity 11 | import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity 12 | 13 | @Database( 14 | version = 13, 15 | entities = [ 16 | RecipeSummaryEntity::class, 17 | RecipeEntity::class, 18 | RecipeIngredientEntity::class, 19 | RecipeInstructionEntity::class, 20 | RecipeIngredientToInstructionEntity::class, 21 | ] 22 | ) 23 | @TypeConverters(RoomTypeConverters::class) 24 | internal abstract class AppDb : RoomDatabase() { 25 | 26 | abstract fun recipeDao(): RecipeDao 27 | } -------------------------------------------------------------------------------- /database/src/main/kotlin/gq/kirmanak/mealient/database/DatabaseModule.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.database 2 | 3 | import android.content.Context 4 | import androidx.room.Room 5 | import dagger.Binds 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 gq.kirmanak.mealient.database.recipe.RecipeDao 12 | import gq.kirmanak.mealient.database.recipe.RecipeStorage 13 | import gq.kirmanak.mealient.database.recipe.RecipeStorageImpl 14 | import javax.inject.Singleton 15 | 16 | @Module 17 | @InstallIn(SingletonComponent::class) 18 | internal interface DatabaseModule { 19 | 20 | companion object { 21 | @Provides 22 | @Singleton 23 | fun createDb(@ApplicationContext context: Context): AppDb = 24 | Room.databaseBuilder(context, AppDb::class.java, "app.db") 25 | .fallbackToDestructiveMigration() 26 | .build() 27 | 28 | @Provides 29 | fun provideRecipeDao(db: AppDb): RecipeDao = db.recipeDao() 30 | } 31 | 32 | @Binds 33 | fun provideRecipeStorage(recipeStorageImpl: RecipeStorageImpl): RecipeStorage 34 | } -------------------------------------------------------------------------------- /database/src/main/kotlin/gq/kirmanak/mealient/database/RoomTypeConverters.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.database 2 | 3 | import androidx.room.TypeConverter 4 | import kotlinx.datetime.* 5 | 6 | object RoomTypeConverters { 7 | @TypeConverter 8 | fun localDateTimeToTimestamp(localDateTime: LocalDateTime) = 9 | localDateTime.toInstant(TimeZone.UTC).toEpochMilliseconds() 10 | 11 | @TypeConverter 12 | fun timestampToLocalDateTime(timestamp: Long) = 13 | Instant.fromEpochMilliseconds(timestamp).toLocalDateTime(TimeZone.UTC) 14 | 15 | @TypeConverter 16 | fun localDateToTimeStamp(date: LocalDate) = 17 | localDateTimeToTimestamp(date.atTime(0, 0)) 18 | 19 | @TypeConverter 20 | fun timestampToLocalDate(timestamp: Long) = 21 | timestampToLocalDateTime(timestamp).date 22 | } -------------------------------------------------------------------------------- /database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/RecipeStorage.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.database.recipe 2 | 3 | import androidx.paging.PagingSource 4 | import gq.kirmanak.mealient.database.recipe.entity.RecipeEntity 5 | import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientEntity 6 | import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientToInstructionEntity 7 | import gq.kirmanak.mealient.database.recipe.entity.RecipeInstructionEntity 8 | import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity 9 | import gq.kirmanak.mealient.database.recipe.entity.RecipeWithSummaryAndIngredientsAndInstructions 10 | 11 | interface RecipeStorage { 12 | suspend fun saveRecipes(recipes: List) 13 | 14 | fun queryRecipes(query: String?): PagingSource 15 | 16 | suspend fun refreshAll(recipes: List) 17 | 18 | suspend fun clearAllLocalData() 19 | 20 | suspend fun saveRecipeInfo( 21 | recipe: RecipeEntity, 22 | ingredients: List, 23 | instructions: List, 24 | ingredientToInstruction: List, 25 | ) 26 | 27 | suspend fun queryRecipeInfo(recipeId: String): RecipeWithSummaryAndIngredientsAndInstructions? 28 | 29 | suspend fun updateFavoriteRecipes(favorites: List) 30 | 31 | suspend fun deleteRecipe(entity: RecipeSummaryEntity) 32 | } -------------------------------------------------------------------------------- /database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeEntity.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.database.recipe.entity 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import androidx.room.PrimaryKey 6 | 7 | @Entity(tableName = "recipe") 8 | data class RecipeEntity( 9 | @PrimaryKey @ColumnInfo(name = "recipe_id") val remoteId: String, 10 | @ColumnInfo(name = "recipe_yield") val recipeYield: String, 11 | @ColumnInfo(name = "recipe_disable_amounts", defaultValue = "true") val disableAmounts: Boolean, 12 | ) 13 | -------------------------------------------------------------------------------- /database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeIngredientEntity.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.database.recipe.entity 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import androidx.room.ForeignKey 6 | import androidx.room.PrimaryKey 7 | 8 | @Entity( 9 | tableName = "recipe_ingredient", 10 | foreignKeys = [ 11 | ForeignKey( 12 | entity = RecipeEntity::class, 13 | parentColumns = ["recipe_id"], 14 | childColumns = ["recipe_id"], 15 | onDelete = ForeignKey.CASCADE 16 | ) 17 | ] 18 | ) 19 | data class RecipeIngredientEntity( 20 | @PrimaryKey @ColumnInfo(name = "recipe_ingredient_id") val id: String, 21 | @ColumnInfo(name = "recipe_id", index = true) val recipeId: String, 22 | @ColumnInfo(name = "recipe_ingredient_note") val note: String, 23 | @ColumnInfo(name = "recipe_ingredient_food") val food: String?, 24 | @ColumnInfo(name = "recipe_ingredient_unit") val unit: String?, 25 | @ColumnInfo(name = "recipe_ingredient_quantity") val quantity: Double?, 26 | @ColumnInfo(name = "recipe_ingredient_display") val display: String, 27 | @ColumnInfo(name = "recipe_ingredient_title") val title: String?, 28 | @ColumnInfo(name = "recipe_ingredient_is_food") val isFood: Boolean, 29 | @ColumnInfo(name = "recipe_ingredient_disable_amount") val disableAmount: Boolean, 30 | ) -------------------------------------------------------------------------------- /database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeIngredientToInstructionEntity.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.database.recipe.entity 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import androidx.room.ForeignKey 6 | 7 | @Entity( 8 | tableName = "recipe_ingredient_to_instruction", 9 | foreignKeys = [ 10 | ForeignKey( 11 | entity = RecipeEntity::class, 12 | parentColumns = ["recipe_id"], 13 | childColumns = ["recipe_id"], 14 | onDelete = ForeignKey.CASCADE 15 | ), 16 | ForeignKey( 17 | entity = RecipeIngredientEntity::class, 18 | parentColumns = ["recipe_ingredient_id"], 19 | childColumns = ["ingredient_id"], 20 | onDelete = ForeignKey.CASCADE 21 | ), 22 | ForeignKey( 23 | entity = RecipeInstructionEntity::class, 24 | parentColumns = ["recipe_instruction_id"], 25 | childColumns = ["instruction_id"], 26 | onDelete = ForeignKey.CASCADE 27 | ), 28 | ], 29 | primaryKeys = ["recipe_id", "ingredient_id", "instruction_id"] 30 | ) 31 | data class RecipeIngredientToInstructionEntity( 32 | @ColumnInfo(name = "recipe_id", index = true) val recipeId: String, 33 | @ColumnInfo(name = "ingredient_id", index = true) val ingredientId: String, 34 | @ColumnInfo(name = "instruction_id", index = true) val instructionId: String, 35 | ) 36 | -------------------------------------------------------------------------------- /database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeInstructionEntity.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.database.recipe.entity 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import androidx.room.ForeignKey 6 | import androidx.room.PrimaryKey 7 | 8 | @Entity( 9 | tableName = "recipe_instruction", 10 | foreignKeys = [ 11 | ForeignKey( 12 | entity = RecipeEntity::class, 13 | parentColumns = ["recipe_id"], 14 | childColumns = ["recipe_id"], 15 | onDelete = ForeignKey.CASCADE 16 | ) 17 | ] 18 | ) 19 | data class RecipeInstructionEntity( 20 | @PrimaryKey @ColumnInfo(name = "recipe_instruction_id") val id: String, 21 | @ColumnInfo(name = "recipe_id", index = true) val recipeId: String, 22 | @ColumnInfo(name = "recipe_instruction_text") val text: String, 23 | @ColumnInfo(name = "recipe_instruction_title") val title: String?, 24 | ) 25 | -------------------------------------------------------------------------------- /database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeSummaryEntity.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.database.recipe.entity 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import androidx.room.PrimaryKey 6 | import kotlinx.datetime.LocalDate 7 | 8 | @Entity(tableName = "recipe_summaries") 9 | data class RecipeSummaryEntity( 10 | @PrimaryKey @ColumnInfo(name = "recipe_id") val remoteId: String, 11 | @ColumnInfo(name = "recipe_summaries_name") val name: String, 12 | @ColumnInfo(name = "recipe_summaries_slug") val slug: String, 13 | @ColumnInfo(name = "recipe_summaries_description") val description: String, 14 | @ColumnInfo(name = "recipe_summaries_date_added") val dateAdded: LocalDate, 15 | @ColumnInfo(name = "recipe_summaries_image_id") val imageId: String?, 16 | @ColumnInfo(name = "recipe_summaries_is_favorite") val isFavorite: Boolean, 17 | ) -------------------------------------------------------------------------------- /database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeWithSummaryAndIngredientsAndInstructions.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.database.recipe.entity 2 | 3 | import androidx.room.Embedded 4 | import androidx.room.Relation 5 | 6 | data class RecipeWithSummaryAndIngredientsAndInstructions( 7 | @Embedded val recipeEntity: RecipeEntity, 8 | @Relation( 9 | parentColumn = "recipe_id", 10 | entityColumn = "recipe_id" 11 | ) 12 | val recipeSummaryEntity: RecipeSummaryEntity, 13 | @Relation( 14 | parentColumn = "recipe_id", 15 | entityColumn = "recipe_id" 16 | ) 17 | val recipeIngredients: List, 18 | @Relation( 19 | parentColumn = "recipe_id", 20 | entityColumn = "recipe_id" 21 | ) 22 | val recipeInstructions: List, 23 | @Relation( 24 | parentColumn = "recipe_id", 25 | entityColumn = "recipe_id" 26 | ) 27 | val recipeIngredientToInstructionEntity: List, 28 | ) 29 | -------------------------------------------------------------------------------- /database/src/test/kotlin/gq/kirmanak/mealient/database/RoomTypeConvertersTest.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.database 2 | 3 | import com.google.common.truth.Truth.assertThat 4 | import kotlinx.datetime.LocalDate 5 | import kotlinx.datetime.LocalDateTime 6 | import org.junit.Test 7 | 8 | class RoomTypeConvertersTest { 9 | @Test 10 | fun `when localDateTimeToTimestamp then correctly converts`() { 11 | val input = LocalDateTime.parse("2021-11-13T15:56:33") 12 | val actual = RoomTypeConverters.localDateTimeToTimestamp(input) 13 | assertThat(actual).isEqualTo(1636818993000) 14 | } 15 | 16 | @Test 17 | fun `when timestampToLocalDateTime then correctly converts`() { 18 | val expected = LocalDateTime.parse("2021-11-13T15:58:38") 19 | val actual = RoomTypeConverters.timestampToLocalDateTime(1636819118000) 20 | assertThat(actual).isEqualTo(expected) 21 | } 22 | 23 | @Test 24 | fun `when localDateToTimeStamp then correctly converts`() { 25 | val input = LocalDate.parse("2021-11-13") 26 | val actual = RoomTypeConverters.localDateToTimeStamp(input) 27 | assertThat(actual).isEqualTo(1636761600000) 28 | } 29 | 30 | @Test 31 | fun `when timestampToLocalDate then correctly converts`() { 32 | val expected = LocalDate.parse("2021-11-13") 33 | val actual = RoomTypeConverters.timestampToLocalDate(1636761600000) 34 | assertThat(actual).isEqualTo(expected) 35 | } 36 | } -------------------------------------------------------------------------------- /database_test/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /database_test/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("gq.kirmanak.mealient.library") 3 | } 4 | 5 | android { 6 | namespace = "gq.kirmanak.mealient.database_test" 7 | } 8 | 9 | dependencies { 10 | implementation(project(":database")) 11 | } -------------------------------------------------------------------------------- /datasource/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /datasource/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("gq.kirmanak.mealient.library") 3 | id("dagger.hilt.android.plugin") 4 | id("org.jetbrains.kotlin.plugin.serialization") 5 | alias(libs.plugins.ksp) 6 | } 7 | 8 | android { 9 | defaultConfig { 10 | buildConfigField("Boolean", "LOG_NETWORK", "true") 11 | consumerProguardFiles("consumer-proguard-rules.pro") 12 | } 13 | namespace = "gq.kirmanak.mealient.datasource" 14 | buildFeatures { 15 | buildConfig = true 16 | } 17 | } 18 | 19 | dependencies { 20 | implementation(project(":logging")) 21 | testImplementation(project(":testing")) 22 | 23 | implementation(libs.google.dagger.hiltAndroid) 24 | ksp(libs.google.dagger.hiltCompiler) 25 | kspTest(libs.google.dagger.hiltAndroidCompiler) 26 | testImplementation(libs.google.dagger.hiltAndroidTesting) 27 | 28 | api(libs.jetbrains.kotlinx.datetime) 29 | 30 | implementation(libs.jetbrains.kotlinx.serialization) 31 | 32 | implementation(libs.jetbrains.kotlinx.coroutinesAndroid) 33 | testImplementation(libs.jetbrains.kotlinx.coroutinesTest) 34 | 35 | implementation(platform(libs.okhttp3.bom)) 36 | implementation(libs.okhttp3.okhttp) 37 | implementation(libs.okhttp3.loggingInterceptor) 38 | 39 | implementation(libs.ktor.core) 40 | implementation(libs.ktor.auth) 41 | implementation(libs.ktor.encoding) 42 | implementation(libs.ktor.negotiation) 43 | implementation(libs.ktor.json) 44 | implementation(libs.ktor.okhttp) 45 | 46 | testImplementation(libs.androidx.test.junit) 47 | 48 | testImplementation(libs.google.truth) 49 | 50 | testImplementation(libs.io.mockk) 51 | 52 | debugImplementation(libs.chuckerteam.chucker) 53 | } 54 | -------------------------------------------------------------------------------- /datasource/consumer-proguard-rules.pro: -------------------------------------------------------------------------------- 1 | ##---------------Begin: proguard configuration for Ktor logger ---------- 2 | # https://youtrack.jetbrains.com/issue/KTOR-5528 3 | -dontwarn org.slf4j.impl.StaticLoggerBinder 4 | ##---------------End: proguard configuration for Ktor logger ---------- -------------------------------------------------------------------------------- /datasource/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /datasource/src/debug/kotlin/gq/kirmanak/mealient/DebugModule.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient 2 | 3 | import android.content.Context 4 | import com.chuckerteam.chucker.api.ChuckerCollector 5 | import com.chuckerteam.chucker.api.ChuckerInterceptor 6 | import com.chuckerteam.chucker.api.RetentionManager 7 | import dagger.Module 8 | import dagger.Provides 9 | import dagger.hilt.InstallIn 10 | import dagger.hilt.android.qualifiers.ApplicationContext 11 | import dagger.hilt.components.SingletonComponent 12 | import dagger.multibindings.IntoSet 13 | import okhttp3.Interceptor 14 | 15 | @Module 16 | @InstallIn(SingletonComponent::class) 17 | internal object DebugModule { 18 | 19 | 20 | @Provides 21 | @IntoSet 22 | fun provideChuckerInterceptor(@ApplicationContext context: Context): Interceptor { 23 | val collector = ChuckerCollector( 24 | context = context, 25 | showNotification = true, 26 | retentionPeriod = RetentionManager.Period.ONE_HOUR, 27 | ) 28 | return ChuckerInterceptor.Builder(context) 29 | .collector(collector) 30 | .alwaysReadResponseBody(true) 31 | .build() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/AuthenticationProvider.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource 2 | 3 | interface AuthenticationProvider { 4 | 5 | suspend fun getAuthToken(): String? 6 | 7 | suspend fun logout() 8 | } -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/CertificateCombinedException.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource 2 | 3 | import java.security.cert.CertPathValidatorException 4 | import java.security.cert.CertificateException 5 | import java.security.cert.CertificateExpiredException 6 | import java.security.cert.CertificateNotYetValidException 7 | import java.security.cert.X509Certificate 8 | import javax.net.ssl.SSLPeerUnverifiedException 9 | 10 | 11 | class CertificateCombinedException(val serverCert: X509Certificate) : RuntimeException() { 12 | 13 | var certificateExpiredException: CertificateExpiredException? = null 14 | var certificateNotYetValidException: CertificateNotYetValidException? = null 15 | var certPathValidatorException: CertPathValidatorException? = null 16 | var otherCertificateException: CertificateException? = null 17 | var sslPeerUnverifiedException: SSLPeerUnverifiedException? = null 18 | 19 | fun isException(): Boolean { 20 | return listOf( 21 | certificateExpiredException, 22 | certificateNotYetValidException, 23 | certPathValidatorException, 24 | otherCertificateException, 25 | sslPeerUnverifiedException 26 | ).any { it != null } 27 | } 28 | 29 | companion object { 30 | private const val serialVersionUID: Long = -8875782030758554999L 31 | } 32 | } -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/DataSourceExtensions.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource 2 | 3 | import kotlinx.coroutines.CancellationException 4 | 5 | /** 6 | * Like [runCatching] but rethrows [CancellationException] to support 7 | * cancellation of coroutines. 8 | */ 9 | inline fun runCatchingExceptCancel(block: () -> T): Result = try { 10 | Result.success(block()) 11 | } catch (e: CancellationException) { 12 | throw e 13 | } catch (e: Throwable) { 14 | Result.failure(e) 15 | } 16 | 17 | inline fun Throwable.findCauseAsInstanceOf(): T? { 18 | var cause: Throwable? = this 19 | var previousCause: Throwable? = null 20 | while (cause != null && cause != previousCause && cause !is T) { 21 | previousCause = cause 22 | cause = cause.cause 23 | } 24 | return cause as? T 25 | } -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/NetworkError.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource 2 | 3 | sealed class NetworkError(cause: Throwable) : RuntimeException(cause.message, cause) { 4 | class Unauthorized(cause: Throwable) : NetworkError(cause) 5 | class NoServerConnection(cause: Throwable) : NetworkError(cause) 6 | class NotMealie(cause: Throwable) : NetworkError(cause) 7 | class MalformedUrl(cause: Throwable) : NetworkError(cause) 8 | } 9 | -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/NetworkRequestWrapper.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource 2 | 3 | interface NetworkRequestWrapper { 4 | 5 | suspend fun makeCall( 6 | block: suspend () -> T, 7 | logMethod: () -> String, 8 | logParameters: (() -> String)? = null, 9 | ): Result 10 | 11 | suspend fun makeCallAndHandleUnauthorized( 12 | block: suspend () -> T, 13 | logMethod: () -> String, 14 | logParameters: (() -> String)? = null, 15 | ): T 16 | 17 | } -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/ServerUrlProvider.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource 2 | 3 | interface ServerUrlProvider { 4 | 5 | suspend fun getUrl(): String? 6 | } -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/TokenChangeListener.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource 2 | 3 | fun interface TokenChangeListener { 4 | 5 | fun onTokenChange() 6 | } -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/TrustedCertificatesStore.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource 2 | 3 | import java.security.cert.Certificate 4 | 5 | interface TrustedCertificatesStore { 6 | 7 | fun isTrustedCertificate(cert: Certificate): Boolean 8 | 9 | fun addTrustedCertificate(cert: Certificate) 10 | } -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/CacheBuilderImpl.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource.impl 2 | 3 | import android.content.Context 4 | import android.os.StatFs 5 | import dagger.hilt.android.qualifiers.ApplicationContext 6 | import gq.kirmanak.mealient.logging.Logger 7 | import okhttp3.Cache 8 | import java.io.File 9 | import javax.inject.Inject 10 | 11 | internal class CacheBuilderImpl @Inject constructor( 12 | @ApplicationContext private val context: Context, 13 | private val logger: Logger, 14 | ) { 15 | 16 | fun buildCache(): Cache { 17 | val dir = findCacheDir() 18 | return Cache(dir, calculateDiskCacheSize(dir)) 19 | } 20 | 21 | private fun findCacheDir(): File = File(context.cacheDir, "okhttp") 22 | 23 | private fun calculateDiskCacheSize(dir: File): Long = dir.runCatching { 24 | StatFs(absolutePath).let { 25 | it.blockCountLong * it.blockSizeLong * AVAILABLE_SPACE_PERCENT / 100 26 | } 27 | } 28 | .onFailure { logger.e(it) { "Can't get available space" } } 29 | .getOrNull() 30 | ?.coerceAtLeast(MIN_OKHTTP_CACHE_SIZE) 31 | ?.coerceAtMost(MAX_OKHTTP_CACHE_SIZE) 32 | ?: MIN_OKHTTP_CACHE_SIZE 33 | 34 | companion object { 35 | private const val MIN_OKHTTP_CACHE_SIZE = 5 * 1024 * 1024L // 5MB 36 | private const val MAX_OKHTTP_CACHE_SIZE = 50 * 1024 * 1024L // 50MB 37 | private const val AVAILABLE_SPACE_PERCENT = 2 38 | } 39 | } -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/OkHttpBuilderImpl.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource.impl 2 | 3 | import gq.kirmanak.mealient.logging.Logger 4 | import okhttp3.Interceptor 5 | import okhttp3.OkHttpClient 6 | import javax.inject.Inject 7 | 8 | internal class OkHttpBuilderImpl @Inject constructor( 9 | private val cacheBuilder: CacheBuilderImpl, 10 | // Use @JvmSuppressWildcards because otherwise dagger can't inject it (https://stackoverflow.com/a/43149382) 11 | private val interceptors: Set<@JvmSuppressWildcards Interceptor>, 12 | private val advancedX509TrustManager: AdvancedX509TrustManager, 13 | private val sslSocketFactoryFactory: SslSocketFactoryFactory, 14 | private val logger: Logger, 15 | ) { 16 | 17 | fun buildOkHttp(): OkHttpClient { 18 | logger.v { "buildOkHttp() was called with cacheBuilder = $cacheBuilder, interceptors = $interceptors" } 19 | 20 | val sslSocketFactory = sslSocketFactoryFactory.create() 21 | 22 | return OkHttpClient.Builder().apply { 23 | interceptors.forEach(::addNetworkInterceptor) 24 | sslSocketFactory(sslSocketFactory, advancedX509TrustManager) 25 | cache(cacheBuilder.buildCache()) 26 | }.build() 27 | } 28 | } -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/SslSocketFactoryFactory.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource.impl 2 | 3 | import gq.kirmanak.mealient.logging.Logger 4 | import javax.inject.Inject 5 | import javax.net.ssl.SSLContext 6 | import javax.net.ssl.SSLSocketFactory 7 | import javax.net.ssl.TrustManager 8 | 9 | internal class SslSocketFactoryFactory @Inject constructor( 10 | private val advancedX509TrustManager: AdvancedX509TrustManager, 11 | private val logger: Logger, 12 | ) { 13 | 14 | fun create(): SSLSocketFactory { 15 | val sslContext = buildSSLContext() 16 | sslContext.init(null, arrayOf(advancedX509TrustManager), null) 17 | return sslContext.socketFactory 18 | } 19 | 20 | private fun buildSSLContext(): SSLContext { 21 | return runCatching { 22 | SSLContext.getInstance("TLSv1.3") 23 | }.recoverCatching { 24 | logger.w { "TLSv1.3 is not supported in this device; falling through TLSv1.2" } 25 | SSLContext.getInstance("TLSv1.2") 26 | }.recoverCatching { 27 | logger.w { "TLSv1.2 is not supported in this device; falling through TLSv1.1" } 28 | SSLContext.getInstance("TLSv1.1") 29 | }.recoverCatching { 30 | logger.w { "TLSv1.1 is not supported in this device; falling through TLSv1.0" } 31 | // should be available in any device; see reference of supported protocols in 32 | // http://developer.android.com/reference/javax/net/ssl/SSLSocket.html 33 | SSLContext.getInstance("TLSv1") 34 | }.getOrThrow() 35 | } 36 | } -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/ktor/ContentNegotiationConfiguration.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource.ktor 2 | 3 | import io.ktor.client.HttpClientConfig 4 | import io.ktor.client.engine.HttpClientEngineConfig 5 | import io.ktor.client.plugins.contentnegotiation.ContentNegotiation 6 | import io.ktor.serialization.kotlinx.json.json 7 | import kotlinx.serialization.json.Json 8 | import javax.inject.Inject 9 | 10 | internal class ContentNegotiationConfiguration @Inject constructor( 11 | private val json: Json, 12 | ) : KtorConfiguration { 13 | 14 | override fun configure(config: HttpClientConfig) { 15 | config.install(ContentNegotiation) { 16 | json(json) 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/ktor/EncodingKtorConfiguration.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource.ktor 2 | 3 | import io.ktor.client.HttpClientConfig 4 | import io.ktor.client.engine.HttpClientEngineConfig 5 | import io.ktor.client.plugins.compression.ContentEncoding 6 | import javax.inject.Inject 7 | 8 | internal class EncodingKtorConfiguration @Inject constructor() : KtorConfiguration { 9 | 10 | override fun configure(config: HttpClientConfig) { 11 | config.install(ContentEncoding) { 12 | gzip() 13 | deflate() 14 | identity() 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/ktor/KtorClientBuilder.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource.ktor 2 | 3 | import io.ktor.client.HttpClient 4 | 5 | internal interface KtorClientBuilder { 6 | 7 | fun buildKtorClient(): HttpClient 8 | } -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/ktor/KtorClientBuilderImpl.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource.ktor 2 | 3 | import gq.kirmanak.mealient.logging.Logger 4 | import io.ktor.client.HttpClient 5 | import io.ktor.client.engine.okhttp.OkHttp 6 | import okhttp3.OkHttpClient 7 | import javax.inject.Inject 8 | 9 | internal class KtorClientBuilderImpl @Inject constructor( 10 | private val configurators: Set<@JvmSuppressWildcards KtorConfiguration>, 11 | private val logger: Logger, 12 | private val okHttpClient: OkHttpClient, 13 | ) : KtorClientBuilder { 14 | 15 | override fun buildKtorClient(): HttpClient { 16 | logger.v { "buildKtorClient() called" } 17 | 18 | val client = HttpClient(OkHttp) { 19 | expectSuccess = true 20 | 21 | configurators.forEach { 22 | it.configure(config = this) 23 | } 24 | 25 | engine { 26 | preconfigured = okHttpClient 27 | } 28 | } 29 | 30 | return client 31 | } 32 | } -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/ktor/KtorConfiguration.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource.ktor 2 | 3 | import io.ktor.client.HttpClientConfig 4 | import io.ktor.client.engine.HttpClientEngineConfig 5 | 6 | internal interface KtorConfiguration { 7 | 8 | fun configure(config: HttpClientConfig) 9 | } -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/ktor/KtorModule.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource.ktor 2 | 3 | import dagger.Binds 4 | import dagger.Module 5 | import dagger.Provides 6 | import dagger.hilt.InstallIn 7 | import dagger.hilt.components.SingletonComponent 8 | import dagger.multibindings.IntoSet 9 | import gq.kirmanak.mealient.datasource.TokenChangeListener 10 | import io.ktor.client.HttpClient 11 | import javax.inject.Singleton 12 | 13 | @Module 14 | @InstallIn(SingletonComponent::class) 15 | internal interface KtorModule { 16 | 17 | companion object { 18 | 19 | @Provides 20 | @Singleton 21 | fun provideClient(builder: KtorClientBuilder): HttpClient = builder.buildKtorClient() 22 | } 23 | 24 | @Binds 25 | @IntoSet 26 | fun bindAuthKtorConfiguration(impl: AuthKtorConfiguration) : KtorConfiguration 27 | 28 | @Binds 29 | @IntoSet 30 | fun bindEncodingKtorConfiguration(impl: EncodingKtorConfiguration) : KtorConfiguration 31 | 32 | @Binds 33 | @IntoSet 34 | fun bindContentNegotiationConfiguration(impl: ContentNegotiationConfiguration) : KtorConfiguration 35 | 36 | @Binds 37 | fun bindKtorClientBuilder(impl: KtorClientBuilderImpl) : KtorClientBuilder 38 | 39 | @Binds 40 | fun bindSignOutHandler(impl: TokenChangeListenerKtor): TokenChangeListener 41 | } -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/ktor/TokenChangeListenerKtor.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource.ktor 2 | 3 | import gq.kirmanak.mealient.datasource.TokenChangeListener 4 | import gq.kirmanak.mealient.logging.Logger 5 | import io.ktor.client.HttpClient 6 | import io.ktor.client.plugins.auth.Auth 7 | import io.ktor.client.plugins.auth.providers.BearerAuthProvider 8 | import io.ktor.client.plugins.plugin 9 | import javax.inject.Inject 10 | 11 | internal class TokenChangeListenerKtor @Inject constructor( 12 | private val httpClient: HttpClient, 13 | private val logger: Logger, 14 | ) : TokenChangeListener { 15 | 16 | override fun onTokenChange() { 17 | logger.v { "onTokenChange() called" } 18 | httpClient.plugin(Auth) 19 | .providers 20 | .filterIsInstance() 21 | .forEach { 22 | logger.d { "onTokenChange(): removing the token" } 23 | it.clearToken() 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/AddRecipeInfo.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource.models 2 | 3 | data class AddRecipeInfo( 4 | val name: String, 5 | val description: String, 6 | val recipeYield: String, 7 | val recipeIngredient: List, 8 | val recipeInstructions: List, 9 | val settings: AddRecipeSettingsInfo, 10 | ) 11 | 12 | data class AddRecipeSettingsInfo( 13 | val disableComments: Boolean, 14 | val public: Boolean, 15 | ) 16 | 17 | data class AddRecipeIngredientInfo( 18 | val note: String, 19 | ) 20 | 21 | data class AddRecipeInstructionInfo( 22 | val text: String, 23 | ) 24 | -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/CreateApiTokenRequest.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource.models 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class CreateApiTokenRequest( 8 | @SerialName("name") val name: String, 9 | ) 10 | -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/CreateApiTokenResponse.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource.models 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class CreateApiTokenResponse( 8 | @SerialName("token") val token: String, 9 | ) -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/CreateRecipeRequest.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource.models 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class CreateRecipeRequest( 8 | @SerialName("name") val name: String, 9 | ) 10 | -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/CreateShoppingListItemRequest.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource.models 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class CreateShoppingListItemRequest( 8 | @SerialName("shopping_list_id") val shoppingListId: String, 9 | @SerialName("checked") val checked: Boolean, 10 | @SerialName("position") val position: Int?, 11 | @SerialName("is_food") val isFood: Boolean, 12 | @SerialName("note") val note: String, 13 | @SerialName("quantity") val quantity: Double, 14 | @SerialName("food_id") val foodId: String?, 15 | @SerialName("unit_id") val unitId: String?, 16 | ) 17 | -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/CreateShoppingListRequest.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource.models 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class CreateShoppingListRequest( 8 | @SerialName("name") val name: String, 9 | ) 10 | -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/ErrorDetail.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource.models 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class ErrorDetail( 8 | @SerialName("detail") val detail: String? = null, 9 | ) -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/GetFoodsResponse.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource.models 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class GetFoodsResponse( 8 | @SerialName("items") val items: List, 9 | ) 10 | 11 | @Serializable 12 | data class GetFoodResponse( 13 | @SerialName("name") val name: String, 14 | @SerialName("id") val id: String, 15 | @SerialName("pluralName") val pluralName: String? = null 16 | ) 17 | -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/GetItemLabelResponse.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource.models 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class GetItemLabelResponse( 8 | @SerialName("name") val name: String, 9 | @SerialName("color") val color: String, 10 | @SerialName("groupId") val grpId: String, 11 | @SerialName("id") val id: String 12 | ) -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/GetRecipeResponse.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource.models 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class GetRecipeResponse( 8 | @SerialName("id") val remoteId: String, 9 | @SerialName("name") val name: String, 10 | @SerialName("recipeYield") val recipeYield: String = "", 11 | @SerialName("recipeIngredient") val ingredients: List = emptyList(), 12 | @SerialName("recipeInstructions") val instructions: List = emptyList(), 13 | @SerialName("settings") val settings: GetRecipeSettingsResponse? = null, 14 | ) 15 | 16 | @Serializable 17 | data class GetRecipeSettingsResponse( 18 | @SerialName("disableAmount") val disableAmount: Boolean, 19 | ) 20 | 21 | @Serializable 22 | data class GetRecipeIngredientResponse( 23 | @SerialName("note") val note: String = "", 24 | @SerialName("unit") val unit: GetUnitResponse?, 25 | @SerialName("food") val food: GetFoodResponse?, 26 | @SerialName("quantity") val quantity: Double?, 27 | @SerialName("display") val display: String, 28 | @SerialName("referenceId") val referenceId: String, 29 | @SerialName("title") val title: String?, 30 | @SerialName("isFood") val isFood: Boolean, 31 | @SerialName("disableAmount") val disableAmount: Boolean, 32 | ) 33 | 34 | @Serializable 35 | data class GetRecipeInstructionResponse( 36 | @SerialName("id") val id: String, 37 | @SerialName("title") val title: String = "", 38 | @SerialName("text") val text: String, 39 | @SerialName("ingredientReferences") val ingredientReferences: List = emptyList(), 40 | ) 41 | 42 | @Serializable 43 | data class GetRecipeInstructionIngredientReference( 44 | @SerialName("referenceId") val referenceId: String, 45 | ) -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/GetRecipeSummaryResponse.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource.models 2 | 3 | import kotlinx.datetime.LocalDate 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class GetRecipeSummaryResponse( 9 | @SerialName("id") val remoteId: String, 10 | @SerialName("name") val name: String, 11 | @SerialName("slug") val slug: String, 12 | @SerialName("description") val description: String = "", 13 | @SerialName("dateAdded") val dateAdded: LocalDate 14 | ) -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/GetRecipesResponse.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource.models 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class GetRecipesResponse( 8 | @SerialName("items") val items: List, 9 | ) 10 | -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/GetShoppingListResponse.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource.models 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class GetShoppingListResponse( 8 | @SerialName("id") val id: String, 9 | @SerialName("groupId") val groupId: String, 10 | @SerialName("name") val name: String = "", 11 | @SerialName("listItems") val listItems: List = emptyList(), 12 | @SerialName("recipeReferences") val recipeReferences: List, 13 | ) 14 | 15 | @Serializable 16 | data class GetShoppingListItemResponse( 17 | @SerialName("shoppingListId") val shoppingListId: String, 18 | @SerialName("id") val id: String, 19 | @SerialName("checked") val checked: Boolean = false, 20 | @SerialName("position") val position: Int = 0, 21 | @SerialName("isFood") val isFood: Boolean = false, 22 | @SerialName("note") val note: String = "", 23 | @SerialName("quantity") val quantity: Double = 0.0, 24 | @SerialName("unit") val unit: GetUnitResponse? = null, 25 | @SerialName("food") val food: GetFoodResponse? = null, 26 | @SerialName("label") val label: GetItemLabelResponse? = null, 27 | @SerialName("recipeReferences") val recipeReferences: List = emptyList(), 28 | ) 29 | 30 | @Serializable 31 | data class GetShoppingListItemRecipeReferenceResponse( 32 | @SerialName("recipeId") val recipeId: String, 33 | @SerialName("recipeQuantity") val recipeQuantity: Double = 0.0 34 | ) 35 | 36 | @Serializable 37 | data class GetShoppingListItemRecipeReferenceFullResponse( 38 | @SerialName("id") val id: String, 39 | @SerialName("shoppingListId") val shoppingListId: String, 40 | @SerialName("recipeId") val recipeId: String, 41 | @SerialName("recipeQuantity") val recipeQuantity: Double = 0.0, 42 | @SerialName("recipe") val recipe: GetRecipeResponse, 43 | ) 44 | -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/GetShoppingListsResponse.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource.models 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class GetShoppingListsResponse( 8 | @SerialName("page") val page: Int, 9 | @SerialName("per_page") val perPage: Int, 10 | @SerialName("total") val total: Int, 11 | @SerialName("total_pages") val totalPages: Int, 12 | @SerialName("items") val items: List, 13 | ) 14 | -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/GetShoppingListsSummaryResponse.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource.models 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class GetShoppingListsSummaryResponse( 8 | @SerialName("id") val id: String, 9 | @SerialName("name") val name: String?, 10 | ) -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/GetTokenResponse.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource.models 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class GetTokenResponse( 8 | @SerialName("access_token") val accessToken: String, 9 | ) -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/GetUnitsResponse.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource.models 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class GetUnitsResponse( 8 | @SerialName("items") val items: List 9 | ) 10 | 11 | @Serializable 12 | data class GetUnitResponse( 13 | @SerialName("name") val name: String, 14 | @SerialName("pluralName") val pluralName: String? = null, 15 | @SerialName("id") val id: String 16 | ) 17 | -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/GetUserInfoResponse.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource.models 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class GetUserInfoResponse( 8 | @SerialName("id") val id: String, 9 | @SerialName("favoriteRecipes") val favoriteRecipes: List = emptyList(), 10 | ) 11 | -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/ParseRecipeURLRequest.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource.models 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class ParseRecipeURLRequest( 8 | @SerialName("url") val url: String, 9 | @SerialName("includeTags") val includeTags: Boolean 10 | ) 11 | -------------------------------------------------------------------------------- /datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/VersionResponse.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource.models 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class VersionResponse( 8 | @SerialName("version") val version: String, 9 | ) -------------------------------------------------------------------------------- /datasource/src/test/kotlin/gq/kirmanak/mealient/datasource/FakeProvider.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datasource 2 | 3 | import javax.inject.Provider 4 | 5 | data class FakeProvider( 6 | val value: T, 7 | ) : Provider { 8 | 9 | override fun get(): T = value 10 | } 11 | -------------------------------------------------------------------------------- /datasource_test/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /datasource_test/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("gq.kirmanak.mealient.library") 3 | } 4 | 5 | android { 6 | namespace = "gq.kirmanak.mealient.datasource_test" 7 | } 8 | 9 | dependencies { 10 | implementation(project(":datasource")) 11 | } 12 | -------------------------------------------------------------------------------- /datastore/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /datastore/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("gq.kirmanak.mealient.library") 3 | id("dagger.hilt.android.plugin") 4 | alias(libs.plugins.protobuf) 5 | alias(libs.plugins.ksp) 6 | } 7 | 8 | android { 9 | namespace = "gq.kirmanak.mealient.datastore" 10 | } 11 | 12 | dependencies { 13 | implementation(project(":logging")) 14 | 15 | implementation(libs.androidx.datastore.preferences) 16 | implementation(libs.androidx.datastore.datastore) 17 | 18 | implementation(libs.google.protobuf.javalite) 19 | 20 | implementation(libs.androidx.security.crypto) 21 | 22 | implementation(libs.google.dagger.hiltAndroid) 23 | ksp(libs.google.dagger.hiltCompiler) 24 | kspTest(libs.google.dagger.hiltAndroidCompiler) 25 | testImplementation(libs.google.dagger.hiltAndroidTesting) 26 | 27 | implementation(libs.jetbrains.kotlinx.datetime) 28 | 29 | implementation(libs.jetbrains.kotlinx.coroutinesAndroid) 30 | testImplementation(libs.jetbrains.kotlinx.coroutinesTest) 31 | 32 | testImplementation(libs.androidx.test.junit) 33 | 34 | testImplementation(libs.google.truth) 35 | 36 | testImplementation(libs.io.mockk) 37 | } 38 | 39 | protobuf { 40 | protoc { 41 | artifact = libs.google.protobuf.protoc.get().toString() 42 | } 43 | 44 | generateProtoTasks { 45 | all().forEach { task -> 46 | task.builtins { 47 | val java by registering { 48 | option("lite") 49 | } 50 | } 51 | } 52 | } 53 | } 54 | 55 | ksp { 56 | allowSourcesFromOtherPlugins = true 57 | } 58 | -------------------------------------------------------------------------------- /datastore/src/main/kotlin/gq/kirmanak/mealient/datastore/DataStoreModule.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datastore 2 | 3 | import android.content.Context 4 | import android.content.SharedPreferences 5 | import androidx.datastore.core.DataStore 6 | import androidx.datastore.core.DataStoreFactory 7 | import androidx.datastore.dataStoreFile 8 | import androidx.security.crypto.EncryptedSharedPreferences 9 | import androidx.security.crypto.MasterKeys 10 | import dagger.Module 11 | import dagger.Provides 12 | import dagger.hilt.InstallIn 13 | import dagger.hilt.android.qualifiers.ApplicationContext 14 | import dagger.hilt.components.SingletonComponent 15 | import gq.kirmanak.mealient.datastore.recipe.AddRecipeInput 16 | import gq.kirmanak.mealient.datastore.recipe.AddRecipeInputSerializer 17 | import javax.inject.Named 18 | import javax.inject.Singleton 19 | 20 | @Module 21 | @InstallIn(SingletonComponent::class) 22 | interface DataStoreModule { 23 | 24 | companion object { 25 | const val ENCRYPTED = "encrypted" 26 | 27 | @Provides 28 | @Singleton 29 | fun provideAddRecipeInputStore( 30 | @ApplicationContext context: Context 31 | ): DataStore = DataStoreFactory.create(AddRecipeInputSerializer) { 32 | context.dataStoreFile("add_recipe_input") 33 | } 34 | 35 | @Provides 36 | @Singleton 37 | @Named(ENCRYPTED) 38 | fun provideEncryptedSharedPreferences( 39 | @ApplicationContext applicationContext: Context, 40 | ): SharedPreferences { 41 | val keyGenParameterSpec = MasterKeys.AES256_GCM_SPEC 42 | val mainKeyAlias = MasterKeys.getOrCreate(keyGenParameterSpec) 43 | return EncryptedSharedPreferences.create( 44 | ENCRYPTED, 45 | mainKeyAlias, 46 | applicationContext, 47 | EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, 48 | EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM 49 | ) 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /datastore/src/main/kotlin/gq/kirmanak/mealient/datastore/recipe/AddRecipeDraft.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datastore.recipe 2 | 3 | data class AddRecipeDraft( 4 | val recipeName: String, 5 | val recipeDescription: String, 6 | val recipeYield: String, 7 | val recipeInstructions: List, 8 | val recipeIngredients: List, 9 | val isRecipePublic: Boolean, 10 | val areCommentsDisabled: Boolean, 11 | ) 12 | -------------------------------------------------------------------------------- /datastore/src/main/kotlin/gq/kirmanak/mealient/datastore/recipe/AddRecipeInputSerializer.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datastore.recipe 2 | 3 | import androidx.datastore.core.CorruptionException 4 | import androidx.datastore.core.Serializer 5 | import com.google.protobuf.InvalidProtocolBufferException 6 | import java.io.InputStream 7 | import java.io.OutputStream 8 | 9 | object AddRecipeInputSerializer : Serializer { 10 | override val defaultValue: AddRecipeInput = AddRecipeInput.getDefaultInstance() 11 | 12 | override suspend fun readFrom(input: InputStream): AddRecipeInput = try { 13 | AddRecipeInput.parseFrom(input) 14 | } catch (e: InvalidProtocolBufferException) { 15 | throw CorruptionException("Can't read proto file", e) 16 | } 17 | 18 | override suspend fun writeTo(t: AddRecipeInput, output: OutputStream) = t.writeTo(output) 19 | } -------------------------------------------------------------------------------- /datastore/src/main/kotlin/gq/kirmanak/mealient/datastore/recipe/AddRecipeStorage.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datastore.recipe 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | 5 | interface AddRecipeStorage { 6 | 7 | val updates: Flow 8 | 9 | suspend fun save(addRecipeDraft: AddRecipeDraft) 10 | 11 | suspend fun clear() 12 | } -------------------------------------------------------------------------------- /datastore/src/main/kotlin/gq/kirmanak/mealient/datastore/recipe/AddRecipeStorageImpl.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datastore.recipe 2 | 3 | import androidx.datastore.core.DataStore 4 | import gq.kirmanak.mealient.logging.Logger 5 | import kotlinx.coroutines.flow.Flow 6 | import kotlinx.coroutines.flow.map 7 | import javax.inject.Inject 8 | 9 | class AddRecipeStorageImpl @Inject constructor( 10 | private val dataStore: DataStore, 11 | private val logger: Logger, 12 | ) : AddRecipeStorage { 13 | 14 | override val updates: Flow 15 | get() = dataStore.data.map { 16 | AddRecipeDraft( 17 | recipeName = it.recipeName, 18 | recipeDescription = it.recipeDescription, 19 | recipeYield = it.recipeYield, 20 | recipeInstructions = it.recipeInstructionsList, 21 | recipeIngredients = it.recipeIngredientsList, 22 | isRecipePublic = it.isRecipePublic, 23 | areCommentsDisabled = it.areCommentsDisabled, 24 | ) 25 | } 26 | 27 | override suspend fun save(addRecipeDraft: AddRecipeDraft) { 28 | logger.v { "save() called with: addRecipeDraft = $addRecipeDraft" } 29 | val input = AddRecipeInput.newBuilder() 30 | .setRecipeName(addRecipeDraft.recipeName) 31 | .setRecipeDescription(addRecipeDraft.recipeDescription) 32 | .setRecipeYield(addRecipeDraft.recipeYield) 33 | .setIsRecipePublic(addRecipeDraft.isRecipePublic) 34 | .setAreCommentsDisabled(addRecipeDraft.areCommentsDisabled) 35 | .addAllRecipeIngredients(addRecipeDraft.recipeIngredients) 36 | .addAllRecipeInstructions(addRecipeDraft.recipeInstructions) 37 | .build() 38 | dataStore.updateData { input } 39 | } 40 | 41 | override suspend fun clear() { 42 | logger.v { "clear() called" } 43 | dataStore.updateData { AddRecipeInput.getDefaultInstance() } 44 | } 45 | } -------------------------------------------------------------------------------- /datastore/src/main/proto/AddRecipeInput.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | option java_package = "gq.kirmanak.mealient.datastore.recipe"; 4 | option java_multiple_files = true; 5 | 6 | message AddRecipeInput { 7 | string recipeName = 1; 8 | string recipeDescription = 2; 9 | string recipeYield = 3; 10 | repeated string recipeInstructions = 4; 11 | repeated string recipeIngredients = 5; 12 | bool isRecipePublic = 6; 13 | bool areCommentsDisabled = 7; 14 | } -------------------------------------------------------------------------------- /datastore_test/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /datastore_test/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("gq.kirmanak.mealient.library") 3 | } 4 | 5 | android { 6 | namespace = "gq.kirmanak.mealient.datastore_test" 7 | } 8 | 9 | dependencies { 10 | implementation(project(":datastore")) 11 | } 12 | -------------------------------------------------------------------------------- /datastore_test/src/main/kotlin/gq/kirmanak/mealient/datastore_test/TestData.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.datastore_test 2 | 3 | import gq.kirmanak.mealient.datastore.recipe.AddRecipeDraft 4 | 5 | val PORRIDGE_RECIPE_DRAFT = AddRecipeDraft( 6 | recipeName = "Porridge", 7 | recipeDescription = "A tasty porridge", 8 | recipeYield = "3 servings", 9 | recipeInstructions = listOf("Mix the ingredients", "Boil the ingredients"), 10 | recipeIngredients = listOf("2 oz of white milk", "2 oz of white sugar"), 11 | isRecipePublic = true, 12 | areCommentsDisabled = false, 13 | ) 14 | 15 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/23.txt: -------------------------------------------------------------------------------- 1 | Share recipes from a web browser to Mealient to save them in Mealie. 2 | Fixed a case when "No recipes" text was shown on top of recipes. 3 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/24.txt: -------------------------------------------------------------------------------- 1 | Ingredient amounts and ingredient titles are now supported. 2 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/25.txt: -------------------------------------------------------------------------------- 1 | Mealient will generate an API token and use it instead of the e-mail and password. 2 | The app will allow deleting recipes or marking them as favorites. 3 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/26.txt: -------------------------------------------------------------------------------- 1 | Mealient will fallback to HTTP if HTTPS is not available. -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/27.txt: -------------------------------------------------------------------------------- 1 | Added support for nightly versions of Mealie. 2 | Added some UI/UX improvements. 3 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/28.txt: -------------------------------------------------------------------------------- 1 | Added support for per-app language settings. 2 | Added translation to Spanish language. -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/29.txt: -------------------------------------------------------------------------------- 1 | Added an option to display the shopping lists. 2 | Added an option to accept self-signed SSL certificates. 3 | Added machine translation to Dutch, German, French, and Portuguese. -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/30.txt: -------------------------------------------------------------------------------- 1 | The app will keep screen on while viewing a recipe -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/31.txt: -------------------------------------------------------------------------------- 1 | Ingredients that are linked to a specific recipe step are shown under that step. -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/32.txt: -------------------------------------------------------------------------------- 1 | Display notes under each recipe ingredient. -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/33.txt: -------------------------------------------------------------------------------- 1 | Removed crash reporting. -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/34.txt: -------------------------------------------------------------------------------- 1 | Fix authentication issues with some Mealie instances. 2 | Allow sending logs to the developer. -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/35.txt: -------------------------------------------------------------------------------- 1 | Now authentication screen is shown automatically when authentication fails. 2 | The recipe ingredient notes are no longer duplicated. -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/36.txt: -------------------------------------------------------------------------------- 1 | Now you can add new shopping lists as well as rename and remove existing ones. 2 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/37.txt: -------------------------------------------------------------------------------- 1 | Fixed incompatibility with Mealie v1.11.0. -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/full_description.txt: -------------------------------------------------------------------------------- 1 | Mealient enables you to easily access the recipes stored in your Mealie instance using your phone. 2 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kirmanak/Mealient/2dd0ec34030716a4038f6d482439aa1a3cc1a08d/fastlane/metadata/android/en-US/images/icon.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kirmanak/Mealient/2dd0ec34030716a4038f6d482439aa1a3cc1a08d/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kirmanak/Mealient/2dd0ec34030716a4038f6d482439aa1a3cc1a08d/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kirmanak/Mealient/2dd0ec34030716a4038f6d482439aa1a3cc1a08d/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kirmanak/Mealient/2dd0ec34030716a4038f6d482439aa1a3cc1a08d/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kirmanak/Mealient/2dd0ec34030716a4038f6d482439aa1a3cc1a08d/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kirmanak/Mealient/2dd0ec34030716a4038f6d482439aa1a3cc1a08d/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/short_description.txt: -------------------------------------------------------------------------------- 1 | Unofficial client for the self-hosted recipe manager Mealie. 2 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/title.txt: -------------------------------------------------------------------------------- 1 | Mealient 2 | -------------------------------------------------------------------------------- /features/shopping_lists/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /features/shopping_lists/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnstableApiUsage") 2 | 3 | plugins { 4 | id("gq.kirmanak.mealient.library") 5 | alias(libs.plugins.ksp) 6 | id("gq.kirmanak.mealient.compose") 7 | id("dagger.hilt.android.plugin") 8 | } 9 | 10 | android { 11 | namespace = "gq.kirmanak.mealient.shopping_list" 12 | } 13 | 14 | ksp { 15 | arg("compose-destinations.generateNavGraphs", "false") 16 | } 17 | 18 | dependencies { 19 | implementation(project(":architecture")) 20 | implementation(project(":logging")) 21 | implementation(project(":datasource")) 22 | implementation(project(":database")) 23 | implementation(project(":ui")) 24 | implementation(project(":model_mapper")) 25 | implementation(libs.android.material.material) 26 | implementation(libs.androidx.compose.material) 27 | implementation(libs.androidx.compose.materialIconsExtended) 28 | implementation(libs.google.dagger.hiltAndroid) 29 | implementation(libs.androidx.hilt.navigationCompose) 30 | implementation(libs.jetbrains.kotlinx.coroutinesAndroid) 31 | 32 | ksp(libs.google.dagger.hiltCompiler) 33 | 34 | kspTest(libs.google.dagger.hiltAndroidCompiler) 35 | 36 | testImplementation(project(":testing")) 37 | testImplementation(libs.google.dagger.hiltAndroidTesting) 38 | testImplementation(libs.jetbrains.kotlinx.coroutinesTest) 39 | testImplementation(libs.androidx.test.junit) 40 | testImplementation(libs.google.truth) 41 | testImplementation(libs.io.mockk) 42 | } 43 | -------------------------------------------------------------------------------- /features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ShoppingListsModule.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.shopping_lists 2 | 3 | import dagger.Binds 4 | import dagger.Module 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | import gq.kirmanak.mealient.shopping_lists.network.ShoppingListsDataSource 8 | import gq.kirmanak.mealient.shopping_lists.network.ShoppingListsDataSourceImpl 9 | import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsRepo 10 | import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsRepoImpl 11 | 12 | @Module 13 | @InstallIn(SingletonComponent::class) 14 | interface ShoppingListsModule { 15 | 16 | @Binds 17 | fun bindShoppingListsDataSource(impl: ShoppingListsDataSourceImpl): ShoppingListsDataSource 18 | 19 | @Binds 20 | fun bindShoppingListsRepo(impl: ShoppingListsRepoImpl): ShoppingListsRepo 21 | } -------------------------------------------------------------------------------- /features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/network/ShoppingListsDataSource.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.shopping_lists.network 2 | 3 | import gq.kirmanak.mealient.datasource.models.CreateShoppingListItemRequest 4 | import gq.kirmanak.mealient.datasource.models.CreateShoppingListRequest 5 | import gq.kirmanak.mealient.datasource.models.GetFoodResponse 6 | import gq.kirmanak.mealient.datasource.models.GetShoppingListItemResponse 7 | import gq.kirmanak.mealient.datasource.models.GetShoppingListResponse 8 | import gq.kirmanak.mealient.datasource.models.GetShoppingListsSummaryResponse 9 | import gq.kirmanak.mealient.datasource.models.GetUnitResponse 10 | 11 | interface ShoppingListsDataSource { 12 | 13 | suspend fun getAllShoppingLists(): List 14 | 15 | suspend fun getShoppingList(id: String): GetShoppingListResponse 16 | 17 | suspend fun deleteShoppingListItem(id: String) 18 | 19 | suspend fun updateShoppingListItem(item: GetShoppingListItemResponse) 20 | 21 | suspend fun getFoods(): List 22 | 23 | suspend fun getUnits(): List 24 | 25 | suspend fun addShoppingListItem(item: CreateShoppingListItemRequest) 26 | 27 | suspend fun addShoppingList(request: CreateShoppingListRequest) 28 | 29 | suspend fun deleteShoppingList(id: String) 30 | 31 | suspend fun updateShoppingListName(id: String, name: String) 32 | } -------------------------------------------------------------------------------- /features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/repo/ShoppingListsAuthRepo.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.shopping_lists.repo 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | 5 | interface ShoppingListsAuthRepo { 6 | 7 | val isAuthorizedFlow: Flow 8 | } -------------------------------------------------------------------------------- /features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/repo/ShoppingListsRepo.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.shopping_lists.repo 2 | 3 | import gq.kirmanak.mealient.datasource.models.CreateShoppingListItemRequest 4 | import gq.kirmanak.mealient.datasource.models.CreateShoppingListRequest 5 | import gq.kirmanak.mealient.datasource.models.GetFoodResponse 6 | import gq.kirmanak.mealient.datasource.models.GetShoppingListItemResponse 7 | import gq.kirmanak.mealient.datasource.models.GetShoppingListResponse 8 | import gq.kirmanak.mealient.datasource.models.GetShoppingListsSummaryResponse 9 | import gq.kirmanak.mealient.datasource.models.GetUnitResponse 10 | 11 | interface ShoppingListsRepo { 12 | 13 | suspend fun getShoppingLists(): List 14 | 15 | suspend fun getShoppingList(id: String): GetShoppingListResponse 16 | 17 | suspend fun deleteShoppingListItem(id: String) 18 | 19 | suspend fun updateShoppingListItem(item: GetShoppingListItemResponse) 20 | 21 | suspend fun getFoods(): List 22 | 23 | suspend fun getUnits(): List 24 | 25 | suspend fun addShoppingListItem(item: CreateShoppingListItemRequest) 26 | 27 | suspend fun addShoppingList(request: CreateShoppingListRequest) 28 | 29 | suspend fun deleteShoppingList(id: String) 30 | 31 | suspend fun updateShoppingListName(id: String, name: String) 32 | } -------------------------------------------------------------------------------- /features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/GetErrorMessage.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.shopping_lists.ui.composables 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.res.stringResource 5 | import gq.kirmanak.mealient.datasource.NetworkError 6 | import gq.kirmanak.mealient.shopping_list.R 7 | 8 | @Composable 9 | fun getErrorMessage(error: Throwable): String = when (error) { 10 | is NetworkError.Unauthorized -> stringResource(R.string.shopping_lists_screen_unauthorized_error) 11 | is NetworkError.NoServerConnection -> stringResource(R.string.shopping_lists_screen_no_connection) 12 | else -> error.message ?: stringResource(R.string.shopping_lists_screen_unknown_error) 13 | } -------------------------------------------------------------------------------- /features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/details/ShoppingListData.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.shopping_lists.ui.details 2 | 3 | import gq.kirmanak.mealient.datasource.models.GetFoodResponse 4 | import gq.kirmanak.mealient.datasource.models.GetShoppingListResponse 5 | import gq.kirmanak.mealient.datasource.models.GetUnitResponse 6 | 7 | data class ShoppingListData( 8 | val foods: List, 9 | val units: List, 10 | val shoppingList: GetShoppingListResponse, 11 | ) 12 | -------------------------------------------------------------------------------- /features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/details/ShoppingListEditingState.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.shopping_lists.ui.details 2 | 3 | import gq.kirmanak.mealient.datasource.models.GetShoppingListItemResponse 4 | 5 | data class ShoppingListEditingState( 6 | val deletedItemIds: Set = emptySet(), 7 | val editingItemIds: Set = emptySet(), 8 | val modifiedItems: Map = emptyMap(), 9 | val newItems: List = emptyList(), 10 | ) 11 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx6g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g 2 | org.gradle.parallel=true 3 | org.gradle.caching=true 4 | android.useAndroidX=true 5 | android.enableJetifier=false 6 | kotlin.code.style=official 7 | android.nonTransitiveRClass=false 8 | android.nonFinalResIds=false -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kirmanak/Mealient/2dd0ec34030716a4038f6d482439aa1a3cc1a08d/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionSha256Sum=31c55713e40233a8303827ceb42ca48a47267a0ad4bab9177123121e71524c26 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip 5 | networkTimeout=10000 6 | validateDistributionUrl=true 7 | zipStoreBase=GRADLE_USER_HOME 8 | zipStorePath=wrapper/dists 9 | -------------------------------------------------------------------------------- /lint.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /logging/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /logging/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("gq.kirmanak.mealient.library") 3 | id("dagger.hilt.android.plugin") 4 | alias(libs.plugins.ksp) 5 | } 6 | 7 | android { 8 | namespace = "gq.kirmanak.mealient.logging" 9 | } 10 | 11 | dependencies { 12 | implementation(project(":architecture")) 13 | 14 | implementation(libs.google.dagger.hiltAndroid) 15 | ksp(libs.google.dagger.hiltCompiler) 16 | } -------------------------------------------------------------------------------- /logging/src/main/kotlin/gq/kirmanak/mealient/logging/Appender.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.logging 2 | 3 | interface Appender { 4 | 5 | fun isLoggable(logLevel: LogLevel): Boolean 6 | 7 | fun isLoggable(logLevel: LogLevel, tag: String): Boolean 8 | 9 | fun log(logLevel: LogLevel, tag: String, message: String) 10 | 11 | } -------------------------------------------------------------------------------- /logging/src/main/kotlin/gq/kirmanak/mealient/logging/AppenderModule.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.logging 2 | 3 | import dagger.Binds 4 | import dagger.Module 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | import dagger.multibindings.IntoSet 8 | 9 | @Module 10 | @InstallIn(SingletonComponent::class) 11 | internal interface AppenderModule { 12 | 13 | @Binds 14 | @IntoSet 15 | fun bindLogcatAppender(logcatAppender: LogcatAppender): Appender 16 | 17 | @Binds 18 | @IntoSet 19 | fun bindFileAppender(fileAppender: FileAppender): Appender 20 | } -------------------------------------------------------------------------------- /logging/src/main/kotlin/gq/kirmanak/mealient/logging/LogLevel.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.logging 2 | 3 | enum class LogLevel { VERBOSE, DEBUG, INFO, WARNING, ERROR } -------------------------------------------------------------------------------- /logging/src/main/kotlin/gq/kirmanak/mealient/logging/LogRedactor.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.logging 2 | 3 | interface LogRedactor { 4 | 5 | fun redact(message: String): String 6 | } -------------------------------------------------------------------------------- /logging/src/main/kotlin/gq/kirmanak/mealient/logging/LogcatAppender.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.logging 2 | 3 | import android.util.Log 4 | import gq.kirmanak.mealient.architecture.configuration.BuildConfiguration 5 | import javax.inject.Inject 6 | 7 | internal class LogcatAppender @Inject constructor( 8 | private val buildConfiguration: BuildConfiguration, 9 | ) : Appender { 10 | 11 | private val isLoggable: Boolean 12 | get() = buildConfiguration.isDebug() 13 | 14 | override fun isLoggable(logLevel: LogLevel): Boolean = isLoggable 15 | 16 | override fun isLoggable(logLevel: LogLevel, tag: String): Boolean = isLoggable 17 | 18 | override fun log(logLevel: LogLevel, tag: String, message: String) { 19 | if (message.length < MAX_LOG_LENGTH) { 20 | Log.println(logLevel.priority, tag, message) 21 | return 22 | } 23 | 24 | // Split by line, then ensure each line can fit into Log's maximum length. 25 | var i = 0 26 | val length = message.length 27 | while (i < length) { 28 | var newline = message.indexOf('\n', i) 29 | newline = if (newline != -1) newline else length 30 | do { 31 | val end = newline.coerceAtMost(i + MAX_LOG_LENGTH) 32 | val part = message.substring(i, end) 33 | Log.println(logLevel.priority, tag, part) 34 | i = end 35 | } while (i < newline) 36 | i++ 37 | } 38 | } 39 | 40 | companion object { 41 | private const val MAX_LOG_LENGTH = 4000 42 | } 43 | } 44 | 45 | private val LogLevel.priority: Int 46 | get() = when (this) { 47 | LogLevel.VERBOSE -> Log.VERBOSE 48 | LogLevel.DEBUG -> Log.DEBUG 49 | LogLevel.INFO -> Log.INFO 50 | LogLevel.WARNING -> Log.WARN 51 | LogLevel.ERROR -> Log.ERROR 52 | } -------------------------------------------------------------------------------- /logging/src/main/kotlin/gq/kirmanak/mealient/logging/Logger.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.logging 2 | 3 | import android.content.Context 4 | import java.io.File 5 | 6 | typealias MessageSupplier = () -> String 7 | 8 | private const val LOG_FILE_NAME = "log.txt" 9 | 10 | interface Logger { 11 | 12 | fun v(throwable: Throwable? = null, tag: String? = null, messageSupplier: MessageSupplier) 13 | 14 | fun d(throwable: Throwable? = null, tag: String? = null, messageSupplier: MessageSupplier) 15 | 16 | fun i(throwable: Throwable? = null, tag: String? = null, messageSupplier: MessageSupplier) 17 | 18 | fun w(throwable: Throwable? = null, tag: String? = null, messageSupplier: MessageSupplier) 19 | 20 | fun e(throwable: Throwable? = null, tag: String? = null, messageSupplier: MessageSupplier) 21 | } 22 | 23 | fun Context.getLogFile(): File { 24 | return File(filesDir, LOG_FILE_NAME) 25 | } -------------------------------------------------------------------------------- /logging/src/main/kotlin/gq/kirmanak/mealient/logging/LoggerModule.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.logging 2 | 3 | import dagger.Binds 4 | import dagger.Module 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | 8 | @Module 9 | @InstallIn(SingletonComponent::class) 10 | interface LoggerModule { 11 | 12 | @Binds 13 | fun bindLogger(loggerImpl: LoggerImpl): Logger 14 | 15 | } -------------------------------------------------------------------------------- /model_mapper/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /model_mapper/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("gq.kirmanak.mealient.library") 3 | id("dagger.hilt.android.plugin") 4 | alias(libs.plugins.ksp) 5 | } 6 | 7 | android { 8 | namespace = "gq.kirmanak.mealient.model_mapper" 9 | } 10 | 11 | dependencies { 12 | implementation(project(":database")) 13 | testImplementation(project(":database_test")) 14 | implementation(project(":datasource")) 15 | testImplementation(project(":datasource_test")) 16 | implementation(project(":datastore")) 17 | testImplementation(project(":datastore_test")) 18 | testImplementation(project(":testing")) 19 | 20 | implementation(libs.google.dagger.hiltAndroid) 21 | ksp(libs.google.dagger.hiltCompiler) 22 | kspTest(libs.google.dagger.hiltAndroidCompiler) 23 | testImplementation(libs.google.dagger.hiltAndroidTesting) 24 | 25 | testImplementation(libs.androidx.test.junit) 26 | 27 | testImplementation(libs.google.truth) 28 | 29 | testImplementation(libs.io.mockk) 30 | } 31 | -------------------------------------------------------------------------------- /model_mapper/src/main/kotlin/gq/kirmanak/mealient/model_mapper/ModelMapperModule.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.model_mapper 2 | 3 | import dagger.Binds 4 | import dagger.Module 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | 8 | @Module 9 | @InstallIn(SingletonComponent::class) 10 | interface ModelMapperModule { 11 | 12 | @Binds 13 | fun bindModelMapper(impl: ModelMapperImpl): ModelMapper 14 | } -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnstableApiUsage") 2 | 3 | pluginManagement { 4 | includeBuild("build-logic") 5 | repositories { 6 | google() 7 | mavenCentral() 8 | gradlePluginPortal() 9 | } 10 | } 11 | 12 | plugins { 13 | id("org.gradle.toolchains.foojay-resolver-convention") version("0.8.0") 14 | } 15 | 16 | dependencyResolutionManagement { 17 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 18 | repositories { 19 | google() 20 | mavenCentral() 21 | } 22 | } 23 | 24 | rootProject.name = "Mealient" 25 | 26 | System.setProperty("sonar.gradle.skipCompile", "true") 27 | 28 | include(":app") 29 | include(":architecture") 30 | include(":database") 31 | include(":database_test") 32 | include(":datastore") 33 | include(":datastore_test") 34 | include(":logging") 35 | include(":datasource") 36 | include(":datasource_test") 37 | include(":testing") 38 | include(":ui") 39 | include(":model_mapper") 40 | include(":features:shopping_lists") 41 | -------------------------------------------------------------------------------- /template_module/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /template_module/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("gq.kirmanak.mealient.library") 3 | } 4 | 5 | android { 6 | namespace = "gq.kirmanak.mealient.MODULE_NAME" 7 | } 8 | 9 | dependencies { 10 | } 11 | -------------------------------------------------------------------------------- /testing/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /testing/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("gq.kirmanak.mealient.library") 3 | alias(libs.plugins.ksp) 4 | id("dagger.hilt.android.plugin") 5 | } 6 | 7 | android { 8 | namespace = "gq.kirmanak.mealient.test" 9 | lint { 10 | abortOnError = false 11 | quiet = true 12 | checkReleaseBuilds = false 13 | } 14 | } 15 | 16 | dependencies { 17 | implementation(project(":logging")) 18 | implementation(project(":architecture")) 19 | 20 | implementation(libs.google.dagger.hiltAndroid) 21 | ksp(libs.google.dagger.hiltCompiler) 22 | ksp(libs.google.dagger.hiltAndroidCompiler) 23 | implementation(libs.google.dagger.hiltAndroidTesting) 24 | 25 | implementation(libs.jetbrains.kotlinx.coroutinesAndroid) 26 | implementation(libs.jetbrains.kotlinx.coroutinesTest) 27 | 28 | implementation(libs.androidx.test.junit) 29 | implementation(libs.androidx.coreTesting) 30 | 31 | implementation(libs.google.truth) 32 | 33 | implementation(libs.io.mockk) 34 | 35 | implementation(libs.robolectric) 36 | } 37 | 38 | -------------------------------------------------------------------------------- /testing/src/main/kotlin/gq/kirmanak/mealient/test/BaseUnitTest.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.test 2 | 3 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule 4 | import gq.kirmanak.mealient.architecture.configuration.AppDispatchers 5 | import gq.kirmanak.mealient.logging.Logger 6 | import io.mockk.MockKAnnotations 7 | import kotlinx.coroutines.CoroutineDispatcher 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.ExperimentalCoroutinesApi 10 | import kotlinx.coroutines.test.UnconfinedTestDispatcher 11 | import kotlinx.coroutines.test.resetMain 12 | import kotlinx.coroutines.test.setMain 13 | import org.junit.After 14 | import org.junit.Before 15 | import org.junit.Rule 16 | import org.junit.rules.Timeout 17 | 18 | @OptIn(ExperimentalCoroutinesApi::class) 19 | open class BaseUnitTest { 20 | 21 | @get:Rule(order = 0) 22 | val instantExecutorRule = InstantTaskExecutorRule() 23 | 24 | @get:Rule(order = 1) 25 | val timeoutRule: Timeout = Timeout.seconds(20) 26 | 27 | protected val logger: Logger = FakeLogger() 28 | 29 | lateinit var dispatchers: AppDispatchers 30 | 31 | @Before 32 | open fun setUp() { 33 | MockKAnnotations.init(this) 34 | Dispatchers.setMain(UnconfinedTestDispatcher()) 35 | dispatchers = object : AppDispatchers { 36 | override val io: CoroutineDispatcher = UnconfinedTestDispatcher() 37 | override val main: CoroutineDispatcher = UnconfinedTestDispatcher() 38 | override val default: CoroutineDispatcher = UnconfinedTestDispatcher() 39 | override val unconfined: CoroutineDispatcher = UnconfinedTestDispatcher() 40 | } 41 | } 42 | 43 | @After 44 | fun tearDown() { 45 | Dispatchers.resetMain() 46 | } 47 | } -------------------------------------------------------------------------------- /testing/src/main/kotlin/gq/kirmanak/mealient/test/FakeLogger.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.test 2 | 3 | import gq.kirmanak.mealient.logging.Logger 4 | import gq.kirmanak.mealient.logging.MessageSupplier 5 | import javax.inject.Inject 6 | 7 | class FakeLogger @Inject constructor() : Logger { 8 | override fun v(throwable: Throwable?, tag: String?, messageSupplier: MessageSupplier) { 9 | print("V", throwable, messageSupplier) 10 | } 11 | 12 | override fun d(throwable: Throwable?, tag: String?, messageSupplier: MessageSupplier) { 13 | print("D", throwable, messageSupplier) 14 | } 15 | 16 | override fun i(throwable: Throwable?, tag: String?, messageSupplier: MessageSupplier) { 17 | print("I", throwable, messageSupplier) 18 | } 19 | 20 | override fun w(throwable: Throwable?, tag: String?, messageSupplier: MessageSupplier) { 21 | print("W", throwable, messageSupplier) 22 | } 23 | 24 | override fun e(throwable: Throwable?, tag: String?, messageSupplier: MessageSupplier) { 25 | print("E", throwable, messageSupplier) 26 | } 27 | 28 | private fun print( 29 | level: String, 30 | throwable: Throwable?, 31 | messageSupplier: MessageSupplier, 32 | ) { 33 | println("$level ${messageSupplier()}. ${throwable?.stackTraceToString().orEmpty()}") 34 | } 35 | } -------------------------------------------------------------------------------- /testing/src/main/kotlin/gq/kirmanak/mealient/test/FakeLoggerModule.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.test 2 | 3 | import dagger.Binds 4 | import dagger.Module 5 | import dagger.hilt.components.SingletonComponent 6 | import dagger.hilt.testing.TestInstallIn 7 | import gq.kirmanak.mealient.logging.Logger 8 | import gq.kirmanak.mealient.logging.LoggerModule 9 | 10 | @Module 11 | @TestInstallIn( 12 | components = [SingletonComponent::class], 13 | replaces = [LoggerModule::class] 14 | ) 15 | interface FakeLoggerModule { 16 | 17 | @Binds 18 | fun bindFakeLogger(impl: FakeLogger): Logger 19 | } -------------------------------------------------------------------------------- /testing/src/main/kotlin/gq/kirmanak/mealient/test/HiltRobolectricTest.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.test 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import dagger.hilt.android.testing.HiltAndroidRule 5 | import dagger.hilt.android.testing.HiltTestApplication 6 | import gq.kirmanak.mealient.logging.Logger 7 | import org.junit.Before 8 | import org.junit.Rule 9 | import org.junit.runner.RunWith 10 | import org.robolectric.annotation.Config 11 | import javax.inject.Inject 12 | 13 | @RunWith(AndroidJUnit4::class) 14 | @Config(application = HiltTestApplication::class, manifest = Config.NONE, sdk = [Config.NEWEST_SDK]) 15 | abstract class HiltRobolectricTest { 16 | 17 | @get:Rule 18 | var hiltRule = HiltAndroidRule(this) 19 | 20 | @Inject 21 | lateinit var logger: Logger 22 | 23 | @Before 24 | fun inject() { 25 | hiltRule.inject() 26 | } 27 | } -------------------------------------------------------------------------------- /ui/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /ui/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("gq.kirmanak.mealient.library") 3 | alias(libs.plugins.ksp) 4 | id("gq.kirmanak.mealient.compose") 5 | id("dagger.hilt.android.plugin") 6 | } 7 | 8 | android { 9 | namespace = "gq.kirmanak.mealient.ui" 10 | } 11 | 12 | dependencies { 13 | implementation(project(":logging")) 14 | 15 | implementation(libs.google.dagger.hiltAndroid) 16 | ksp(libs.google.dagger.hiltCompiler) 17 | kspTest(libs.google.dagger.hiltAndroidCompiler) 18 | testImplementation(libs.google.dagger.hiltAndroidTesting) 19 | 20 | implementation(libs.android.material.material) 21 | implementation(libs.androidx.compose.material) 22 | 23 | implementation(libs.androidx.paging.compose) 24 | 25 | testImplementation(libs.androidx.test.junit) 26 | 27 | testImplementation(libs.google.truth) 28 | 29 | testImplementation(libs.io.mockk) 30 | } -------------------------------------------------------------------------------- /ui/src/main/kotlin/gq/kirmanak/mealient/ui/OperationUiState.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.ui 2 | 3 | import android.widget.Button 4 | import android.widget.ProgressBar 5 | import androidx.core.view.isVisible 6 | 7 | sealed class OperationUiState { 8 | 9 | val exceptionOrNull: Throwable? 10 | get() = (this as? Failure)?.exception 11 | 12 | val isSuccess: Boolean 13 | get() = this is Success 14 | 15 | val isProgress: Boolean 16 | get() = this is Progress 17 | 18 | val isFailure: Boolean 19 | get() = this is Failure 20 | 21 | fun updateButtonState(button: Button) { 22 | button.isEnabled = !isProgress 23 | button.isClickable = !isProgress 24 | } 25 | 26 | fun updateProgressState(progressBar: ProgressBar) { 27 | progressBar.isVisible = isProgress 28 | } 29 | 30 | class Initial : OperationUiState() { 31 | override fun equals(other: Any?): Boolean { 32 | if (this === other) return true 33 | return javaClass == other?.javaClass 34 | } 35 | 36 | override fun hashCode(): Int { 37 | return javaClass.hashCode() 38 | } 39 | } 40 | 41 | class Progress : OperationUiState() { 42 | override fun equals(other: Any?): Boolean { 43 | if (this === other) return true 44 | return javaClass == other?.javaClass 45 | } 46 | 47 | override fun hashCode(): Int { 48 | return javaClass.hashCode() 49 | } 50 | } 51 | 52 | data class Failure(val exception: Throwable) : OperationUiState() 53 | 54 | data class Success(val value: T) : OperationUiState() 55 | 56 | companion object { 57 | fun fromResult(result: Result) = result.fold({ Success(it) }, { Failure(it) }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/gq/kirmanak/mealient/ui/Theme.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.ui 2 | 3 | import android.os.Build 4 | import androidx.compose.foundation.isSystemInDarkTheme 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.material3.darkColorScheme 7 | import androidx.compose.material3.dynamicDarkColorScheme 8 | import androidx.compose.material3.dynamicLightColorScheme 9 | import androidx.compose.material3.lightColorScheme 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.platform.LocalContext 12 | import androidx.compose.ui.unit.dp 13 | import com.google.android.material.color.DynamicColors 14 | 15 | @Composable 16 | fun AppTheme( 17 | isDarkTheme: Boolean = isSystemInDarkTheme(), 18 | isDynamicColor: Boolean = DynamicColors.isDynamicColorAvailable(), 19 | content: @Composable () -> Unit 20 | ) { 21 | val colorScheme = when { 22 | Build.VERSION.SDK_INT < Build.VERSION_CODES.S || !isDynamicColor -> { 23 | if (isDarkTheme) darkColorScheme() else lightColorScheme() 24 | } 25 | isDarkTheme -> { 26 | dynamicDarkColorScheme(LocalContext.current) 27 | } 28 | else -> { 29 | dynamicLightColorScheme(LocalContext.current) 30 | } 31 | } 32 | 33 | MaterialTheme( 34 | colorScheme = colorScheme, 35 | content = content 36 | ) 37 | } 38 | 39 | object Dimens { 40 | 41 | val Small = 8.dp 42 | 43 | val Intermediate = 12.dp 44 | 45 | val Medium = 16.dp 46 | 47 | val Large = 24.dp 48 | 49 | } -------------------------------------------------------------------------------- /ui/src/main/kotlin/gq/kirmanak/mealient/ui/UiModule.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.ui 2 | 3 | import dagger.Binds 4 | import dagger.Module 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | import gq.kirmanak.mealient.ui.util.LoadingHelperFactory 8 | import gq.kirmanak.mealient.ui.util.LoadingHelperFactoryImpl 9 | 10 | @Module 11 | @InstallIn(SingletonComponent::class) 12 | internal interface UiModule { 13 | 14 | @Binds 15 | fun bindLoadingHelperFactory(impl: LoadingHelperFactoryImpl): LoadingHelperFactory 16 | } -------------------------------------------------------------------------------- /ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/CenteredProgressIndicator.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.ui.components 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.material3.CircularProgressIndicator 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Alignment 8 | import androidx.compose.ui.Modifier 9 | import gq.kirmanak.mealient.ui.AppTheme 10 | import gq.kirmanak.mealient.ui.preview.ColorSchemePreview 11 | 12 | @Composable 13 | fun CenteredProgressIndicator( 14 | modifier: Modifier = Modifier, 15 | ) { 16 | Box( 17 | modifier = modifier.fillMaxSize(), 18 | contentAlignment = Alignment.Center, 19 | ) { 20 | CircularProgressIndicator() 21 | } 22 | } 23 | 24 | @ColorSchemePreview 25 | @Composable 26 | fun PreviewCenteredProgressIndicator() { 27 | AppTheme { 28 | CenteredProgressIndicator() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/EmptyListError.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.ui.components 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.material3.Button 7 | import androidx.compose.material3.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Alignment 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.semantics.semantics 12 | import androidx.compose.ui.semantics.testTag 13 | import gq.kirmanak.mealient.ui.AppTheme 14 | import gq.kirmanak.mealient.ui.Dimens 15 | import gq.kirmanak.mealient.ui.preview.ColorSchemePreview 16 | 17 | @Composable 18 | fun EmptyListError( 19 | text: String, 20 | modifier: Modifier = Modifier, 21 | onRetry: () -> Unit = {}, 22 | retryButtonText: String? = null, 23 | ) { 24 | Box( 25 | modifier = modifier, 26 | ) { 27 | Column( 28 | modifier = Modifier.align(Alignment.Center), 29 | horizontalAlignment = Alignment.CenterHorizontally, 30 | ) { 31 | Text( 32 | modifier = Modifier 33 | .padding(top = Dimens.Medium) 34 | .semantics { testTag = "empty-list-error-text" }, 35 | text = text, 36 | ) 37 | if (!retryButtonText.isNullOrBlank()) { 38 | Button( 39 | modifier = Modifier.padding(top = Dimens.Medium), 40 | onClick = onRetry, 41 | ) { 42 | Text(text = retryButtonText) 43 | } 44 | } 45 | } 46 | } 47 | } 48 | 49 | @Composable 50 | @ColorSchemePreview 51 | fun PreviewEmptyListError() { 52 | AppTheme { 53 | EmptyListError( 54 | text = "No items in the list", 55 | retryButtonText = "Try again", 56 | onRetry = {} 57 | ) 58 | } 59 | } -------------------------------------------------------------------------------- /ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/ErrorSnackbar.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.ui.components 2 | 3 | import androidx.compose.material3.SnackbarHostState 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.LaunchedEffect 6 | import androidx.compose.runtime.rememberCoroutineScope 7 | import kotlinx.coroutines.launch 8 | 9 | @Composable 10 | fun ErrorSnackbar( 11 | text: String?, 12 | snackbarHostState: SnackbarHostState, 13 | onSnackbarShown: () -> Unit, 14 | ) { 15 | if (text.isNullOrBlank()) { 16 | snackbarHostState.currentSnackbarData?.dismiss() 17 | return 18 | } 19 | 20 | val scope = rememberCoroutineScope() 21 | 22 | LaunchedEffect(snackbarHostState) { 23 | scope.launch { 24 | snackbarHostState.showSnackbar(message = text) 25 | onSnackbarShown() 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/LazyColumnPullRefresh.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.ui.components 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.PaddingValues 6 | import androidx.compose.foundation.lazy.LazyColumn 7 | import androidx.compose.foundation.lazy.LazyListScope 8 | import androidx.compose.foundation.lazy.LazyListState 9 | import androidx.compose.foundation.lazy.rememberLazyListState 10 | import androidx.compose.material.ExperimentalMaterialApi 11 | import androidx.compose.material.pullrefresh.PullRefreshIndicator 12 | import androidx.compose.material.pullrefresh.PullRefreshState 13 | import androidx.compose.material.pullrefresh.pullRefresh 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.ui.Alignment 16 | import androidx.compose.ui.Modifier 17 | 18 | @Composable 19 | @OptIn(ExperimentalMaterialApi::class) 20 | fun LazyColumnPullRefresh( 21 | refreshState: PullRefreshState, 22 | isRefreshing: Boolean, 23 | contentPadding: PaddingValues, 24 | verticalArrangement: Arrangement.Vertical, 25 | lazyColumnContent: LazyListScope.() -> Unit, 26 | modifier: Modifier = Modifier, 27 | lazyListState: LazyListState = rememberLazyListState(), 28 | ) { 29 | Box( 30 | modifier = modifier.pullRefresh(refreshState), 31 | ) { 32 | LazyColumn( 33 | contentPadding = contentPadding, 34 | verticalArrangement = verticalArrangement, 35 | state = lazyListState, 36 | content = lazyColumnContent 37 | ) 38 | 39 | PullRefreshIndicator( 40 | modifier = Modifier.align(Alignment.TopCenter), 41 | refreshing = isRefreshing, 42 | state = refreshState 43 | ) 44 | } 45 | } -------------------------------------------------------------------------------- /ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/LazyPagingColumnPullRefresh.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.ui.components 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.PaddingValues 5 | import androidx.compose.foundation.lazy.LazyListScope 6 | import androidx.compose.material.ExperimentalMaterialApi 7 | import androidx.compose.material.pullrefresh.rememberPullRefreshState 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.unit.dp 11 | import androidx.paging.LoadState 12 | import androidx.paging.compose.LazyPagingItems 13 | 14 | @OptIn(ExperimentalMaterialApi::class) 15 | @Composable 16 | fun LazyPagingColumnPullRefresh( 17 | lazyPagingItems: LazyPagingItems, 18 | modifier: Modifier = Modifier, 19 | contentPadding: PaddingValues = PaddingValues(0.dp), 20 | verticalArrangement: Arrangement.Vertical = Arrangement.Top, 21 | lazyColumnContent: LazyListScope.() -> Unit, 22 | ) { 23 | val isRefreshing = lazyPagingItems.loadState.refresh is LoadState.Loading 24 | 25 | val refreshState = rememberPullRefreshState( 26 | refreshing = isRefreshing, 27 | onRefresh = lazyPagingItems::refresh, 28 | ) 29 | 30 | LazyColumnPullRefresh( 31 | modifier = modifier, 32 | refreshState = refreshState, 33 | isRefreshing = isRefreshing, 34 | contentPadding = contentPadding, 35 | verticalArrangement = verticalArrangement, 36 | lazyColumnContent = lazyColumnContent, 37 | ) 38 | } -------------------------------------------------------------------------------- /ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/TopProgressIndicator.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.ui.components 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.BoxScope 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.material3.LinearProgressIndicator 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Alignment 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.semantics.semantics 11 | import androidx.compose.ui.semantics.testTag 12 | 13 | @Composable 14 | fun TopProgressIndicator( 15 | isLoading: Boolean, 16 | modifier: Modifier = Modifier, 17 | content: @Composable BoxScope.() -> Unit = {}, 18 | ) { 19 | Box( 20 | modifier = modifier, 21 | ) { 22 | if (isLoading) { 23 | LinearProgressIndicator( 24 | modifier = Modifier 25 | .fillMaxWidth() 26 | .align(Alignment.TopCenter) 27 | .semantics { testTag = "progress-indicator" }, 28 | ) 29 | } 30 | 31 | content() 32 | } 33 | } -------------------------------------------------------------------------------- /ui/src/main/kotlin/gq/kirmanak/mealient/ui/preview/ColorSchemePreview.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.ui.preview 2 | 3 | import android.content.res.Configuration.UI_MODE_NIGHT_MASK 4 | import android.content.res.Configuration.UI_MODE_NIGHT_YES 5 | import androidx.compose.ui.tooling.preview.Preview 6 | import androidx.compose.ui.tooling.preview.Wallpapers 7 | 8 | @Preview( 9 | name = "Blue", 10 | group = "Day", 11 | showBackground = true, 12 | wallpaper = Wallpapers.BLUE_DOMINATED_EXAMPLE, 13 | ) 14 | @Preview( 15 | name = "Red", 16 | group = "Day", 17 | showBackground = true, 18 | wallpaper = Wallpapers.RED_DOMINATED_EXAMPLE, 19 | ) 20 | @Preview( 21 | name = "None", 22 | group = "Day", 23 | showBackground = true, 24 | ) 25 | @Preview( 26 | name = "Blue", 27 | group = "Night", 28 | showBackground = true, 29 | wallpaper = Wallpapers.BLUE_DOMINATED_EXAMPLE, 30 | uiMode = UI_MODE_NIGHT_MASK and UI_MODE_NIGHT_YES, 31 | ) 32 | @Preview( 33 | name = "Red", 34 | group = "Night", 35 | showBackground = true, 36 | wallpaper = Wallpapers.RED_DOMINATED_EXAMPLE, 37 | uiMode = UI_MODE_NIGHT_MASK and UI_MODE_NIGHT_YES, 38 | ) 39 | @Preview( 40 | name = "None", 41 | group = "Night", 42 | showBackground = true, 43 | uiMode = UI_MODE_NIGHT_MASK and UI_MODE_NIGHT_YES, 44 | ) 45 | annotation class ColorSchemePreview 46 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/gq/kirmanak/mealient/ui/util/LoadingHelper.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.ui.util 2 | 3 | import kotlinx.coroutines.flow.StateFlow 4 | 5 | interface LoadingHelper { 6 | 7 | val loadingState: StateFlow> 8 | 9 | suspend fun refresh(): Result 10 | } -------------------------------------------------------------------------------- /ui/src/main/kotlin/gq/kirmanak/mealient/ui/util/LoadingHelperFactory.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.ui.util 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | 5 | interface LoadingHelperFactory { 6 | 7 | fun create( 8 | coroutineScope: CoroutineScope, 9 | fetch: suspend () -> Result, 10 | ): LoadingHelper 11 | } -------------------------------------------------------------------------------- /ui/src/main/kotlin/gq/kirmanak/mealient/ui/util/LoadingHelperFactoryImpl.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.ui.util 2 | 3 | import gq.kirmanak.mealient.logging.Logger 4 | import kotlinx.coroutines.CoroutineScope 5 | import javax.inject.Inject 6 | 7 | // @AssistedFactory does not currently support type parameters in the creator method. 8 | // See https://github.com/google/dagger/issues/2279 9 | internal class LoadingHelperFactoryImpl @Inject constructor( 10 | private val logger: Logger 11 | ) : LoadingHelperFactory { 12 | 13 | override fun create( 14 | coroutineScope: CoroutineScope, 15 | fetch: suspend () -> Result, 16 | ): LoadingHelper = LoadingHelperImpl(logger, fetch) 17 | } -------------------------------------------------------------------------------- /ui/src/main/kotlin/gq/kirmanak/mealient/ui/util/LoadingHelperImpl.kt: -------------------------------------------------------------------------------- 1 | package gq.kirmanak.mealient.ui.util 2 | 3 | import gq.kirmanak.mealient.logging.Logger 4 | import kotlinx.coroutines.flow.MutableStateFlow 5 | import kotlinx.coroutines.flow.StateFlow 6 | import kotlinx.coroutines.flow.update 7 | 8 | internal class LoadingHelperImpl( 9 | private val logger: Logger, 10 | private val fetch: suspend () -> Result, 11 | ) : LoadingHelper { 12 | 13 | private val _loadingState = MutableStateFlow>(LoadingStateNoData.InitialLoad) 14 | override val loadingState: StateFlow> = _loadingState 15 | 16 | override suspend fun refresh(): Result { 17 | logger.v { "refresh() called" } 18 | _loadingState.update { currentState -> 19 | when (currentState) { 20 | is LoadingStateWithData -> LoadingStateWithData.Refreshing(currentState.data) 21 | is LoadingStateNoData -> LoadingStateNoData.InitialLoad 22 | } 23 | } 24 | val result = fetch() 25 | _loadingState.update { currentState -> 26 | result.fold( 27 | onSuccess = { data -> 28 | LoadingStateWithData.Success(data) 29 | }, 30 | onFailure = { error -> 31 | when (currentState) { 32 | is LoadingStateWithData -> LoadingStateWithData.Success(currentState.data) 33 | is LoadingStateNoData -> LoadingStateNoData.LoadError(error) 34 | } 35 | }, 36 | ) 37 | } 38 | return result 39 | } 40 | 41 | } -------------------------------------------------------------------------------- /ui/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Mealient 4 | @string/app_name 5 | Open navigation drawer 6 | --------------------------------------------------------------------------------