├── .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 |
6 |
7 |
8 |
9 |
12 |
17 |
18 |
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 |
--------------------------------------------------------------------------------