Compare commits
14 Commits
3c83f740d4
...
1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
2e163f8354
|
|||
|
d10622c382
|
|||
|
e4ea44f766
|
|||
| 7650e6487d | |||
|
7fb8b195f0
|
|||
|
77f48c603d
|
|||
|
2d4214562a
|
|||
|
f7bd6643cb
|
|||
| 1be2cb425c | |||
|
571db144c4
|
|||
| 49c9a6dce1 | |||
|
c4e6d6b69f
|
|||
|
9ecfcc2a74
|
|||
|
f5db153ac2
|
91
.github/workflows/check.yml
vendored
@@ -1,91 +0,0 @@
|
|||||||
name: Check
|
|
||||||
|
|
||||||
on: [ pull_request ]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2
|
|
||||||
with:
|
|
||||||
fastlaneDir: ./fastlane/metadata/android
|
|
||||||
usePlayStoreLocales: true
|
|
||||||
|
|
||||||
- uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: 17
|
|
||||||
|
|
||||||
- name: Setup Gradle
|
|
||||||
uses: gradle/actions/setup-gradle@v4
|
|
||||||
|
|
||||||
- name: Setup Android SDK
|
|
||||||
uses: android-actions/setup-android@v3
|
|
||||||
|
|
||||||
- name: Checks
|
|
||||||
run: ./gradlew check :app:koverXmlReportRelease :app:koverVerifyRelease
|
|
||||||
|
|
||||||
- name: SonarCloud
|
|
||||||
env:
|
|
||||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
|
||||||
run: ./gradlew sonar
|
|
||||||
|
|
||||||
- name: Publish test reports
|
|
||||||
uses: mikepenz/action-junit-report@v4
|
|
||||||
if: always() # always run even if the previous step fails
|
|
||||||
with:
|
|
||||||
report_paths: './**/build/test-results/**/TEST-*.xml'
|
|
||||||
|
|
||||||
uiTests:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
api-level: [ 30 ]
|
|
||||||
steps:
|
|
||||||
- name: Free Disk Space (Ubuntu)
|
|
||||||
uses: jlumbroso/free-disk-space@v1.3.1
|
|
||||||
with:
|
|
||||||
android: false
|
|
||||||
large-packages: true
|
|
||||||
tool-cache: true
|
|
||||||
dotnet: true
|
|
||||||
haskell: true
|
|
||||||
docker-images: true
|
|
||||||
swap-storage: true
|
|
||||||
|
|
||||||
- name: Enable KVM group perms
|
|
||||||
run: |
|
|
||||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
|
||||||
sudo udevadm control --reload-rules
|
|
||||||
sudo udevadm trigger --name-match=kvm
|
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
name: Checkout the code
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- uses: actions/setup-java@v4
|
|
||||||
name: Setup JDK 17
|
|
||||||
with:
|
|
||||||
distribution: 'zulu'
|
|
||||||
java-version: 17
|
|
||||||
|
|
||||||
- name: Setup Gradle
|
|
||||||
uses: gradle/actions/setup-gradle@v4
|
|
||||||
with:
|
|
||||||
validate-wrappers: true
|
|
||||||
gradle-home-cache-cleanup: true
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
uses: reactivecircus/android-emulator-runner@v2
|
|
||||||
with:
|
|
||||||
api-level: ${{ matrix.api-level }}
|
|
||||||
arch: x86_64
|
|
||||||
disable-animations: true
|
|
||||||
disk-size: 6000M
|
|
||||||
heap-size: 600M
|
|
||||||
script: ./gradlew :app:connectedCheck
|
|
||||||
61
.github/workflows/sign.yml
vendored
@@ -1,61 +0,0 @@
|
|||||||
name: Sign
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
sign:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: 17
|
|
||||||
|
|
||||||
- name: Setup Gradle
|
|
||||||
uses: gradle/gradle-build-action@v3
|
|
||||||
|
|
||||||
- name: Setup Android SDK
|
|
||||||
uses: android-actions/setup-android@v3
|
|
||||||
|
|
||||||
- name: Restore keystore
|
|
||||||
env:
|
|
||||||
MEALIENT_KEY_STORE: ${{ secrets.MEALIENT_KEY_STORE }}
|
|
||||||
MEALIENT_KEY_STORE_PASSWORD: ${{ secrets.MEALIENT_KEY_STORE_PASSWORD }}
|
|
||||||
MEALIENT_KEY_ALIAS: ${{ secrets.MEALIENT_KEY_ALIAS }}
|
|
||||||
MEALIENT_KEY_PASSWORD: ${{ secrets.MEALIENT_KEY_PASSWORD }}
|
|
||||||
run: |
|
|
||||||
echo "$MEALIENT_KEY_STORE" | base64 -d > app/keystore.jks
|
|
||||||
echo "storeFile=keystore.jks" > keystore.properties
|
|
||||||
echo "storePassword=$MEALIENT_KEY_STORE_PASSWORD" >> keystore.properties
|
|
||||||
echo "keyAlias=$MEALIENT_KEY_ALIAS" >> keystore.properties
|
|
||||||
echo "keyPassword=$MEALIENT_KEY_PASSWORD" >> keystore.properties
|
|
||||||
|
|
||||||
- name: APK
|
|
||||||
run: |
|
|
||||||
./gradlew build
|
|
||||||
cp app/build/outputs/apk/release/*.apk mealient-release.apk
|
|
||||||
|
|
||||||
- name: Bundle
|
|
||||||
run: |
|
|
||||||
./gradlew bundle
|
|
||||||
cp app/build/outputs/bundle/release/*.aab mealient-release.aab
|
|
||||||
|
|
||||||
- name: Upload release build
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: Release build
|
|
||||||
path: |
|
|
||||||
mealient-release.apk
|
|
||||||
mealient-release.aab
|
|
||||||
|
|
||||||
- name: SonarCloud
|
|
||||||
env:
|
|
||||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
|
||||||
run: ./gradlew sonar
|
|
||||||
3
LICENSE
@@ -1,6 +1,7 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2022, Kirill Kamakin
|
Copyright (c) 2022, Kirill Kamakin
|
||||||
|
Copyright (c) 2025, Atridad Lahiji
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
@@ -16,6 +17,6 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
LIABILITY, WHETHER IN AN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
37
README.md
@@ -1,29 +1,26 @@
|
|||||||
# Mealient
|
# Mealient
|
||||||
|
|
||||||
## DISCLAIMER
|
## USAGE REQUIREMENTS
|
||||||
|
|
||||||
This project is developed independently from the core Mealie project. It is NOT associated with the
|
- Android 8.0 or higher
|
||||||
core Mealie developers. Any issues must be reported to the Mealient repository, NOT the Mealie
|
- A Mealie server running v3 or higher
|
||||||
repository.
|
|
||||||
|
## DOWNLOAD
|
||||||
|
|
||||||
|
You have two options:
|
||||||
|
|
||||||
|
1. Download the latest APK from the Released page
|
||||||
|
2. Use <a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.atridad.mealient%22%2C%22url%22%3A%22https%3A%2F%2Fgit.atri.dad%2Fatridad%2FMealient%2Freleases%22%2C%22author%22%3A%22git.atri.dad%22%2C%22name%22%3A%22Mealient%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla%2F5.0%20(Linux%3B%20Android%2010%3B%20K)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F114.0.0.0%20Mobile%20Safari%2F537.36%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22partialAPKHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D">Obtainium</a>
|
||||||
|
|
||||||
|
## DISCLAIMERS
|
||||||
|
|
||||||
|
This project is developed independently from the core Mealie project. It is NOT associated with the core Mealie developers. **Any issues must be reported to the Mealient repository, NOT the Mealie repository.**
|
||||||
|
|
||||||
|
Also, this is a fork of the original Mealient project. All credit goes to Kirill Kamakin on GitHub for the original project.
|
||||||
|
|
||||||
## What is this?
|
## What is this?
|
||||||
|
|
||||||
An unofficial Android client for [Mealie](https://github.com/mealie-recipes/mealie/). It enables you
|
An **unofficial** Android client for [Mealie](https://github.com/mealie-recipes/mealie/). It enables you
|
||||||
to
|
to
|
||||||
easily access your recipes using an Android device. The main advantage over website is that
|
easily access your recipes using an Android device. The main advantage over website is that
|
||||||
recipe data is stored locally and can be accessed without the Internet connection.
|
recipe data is stored locally and can be accessed without the Internet connection.
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Current version is a very early alpha which supports a small subset of the Mealie capabilities.
|
|
||||||
Displays the list of recipes, some information about each of the recipes, even recipe creation is
|
|
||||||
available!
|
|
||||||
The list of shopping lists is also available, each shopping list can be viewed and modified.
|
|
||||||
|
|
||||||
## How to install
|
|
||||||
|
|
||||||
Download the latest apk from the releases page.
|
|
||||||
|
|
||||||
## Contribution
|
|
||||||
|
|
||||||
Any contribution is greatly appreciated: translations, bug reports, feature requests and any PR.
|
|
||||||
|
|||||||
@@ -13,11 +13,10 @@ plugins {
|
|||||||
android {
|
android {
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.atridad.mealient"
|
applicationId = "com.atridad.mealient"
|
||||||
versionCode = 37
|
versionCode = 39
|
||||||
versionName = "0.4.8"
|
versionName = "1.0.0"
|
||||||
testInstrumentationRunner = "com.atridad.mealient.MealientTestRunner"
|
testInstrumentationRunner = "com.atridad.mealient.MealientTestRunner"
|
||||||
testInstrumentationRunnerArguments += mapOf("clearPackageData" to "true")
|
testInstrumentationRunnerArguments += mapOf("clearPackageData" to "true")
|
||||||
resourceConfigurations += listOf("en", "es", "ru", "fr", "nl", "pt", "de")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
@@ -77,6 +76,7 @@ dependencies {
|
|||||||
implementation(project(":logging"))
|
implementation(project(":logging"))
|
||||||
implementation(project(":ui"))
|
implementation(project(":ui"))
|
||||||
implementation(project(":features:shopping_lists"))
|
implementation(project(":features:shopping_lists"))
|
||||||
|
implementation(project(":features:user_managment"))
|
||||||
implementation(project(":model_mapper"))
|
implementation(project(":model_mapper"))
|
||||||
implementation(libs.android.material.material)
|
implementation(libs.android.material.material)
|
||||||
implementation(libs.androidx.coreKtx)
|
implementation(libs.androidx.coreKtx)
|
||||||
@@ -103,6 +103,7 @@ dependencies {
|
|||||||
kover(project(":datasource"))
|
kover(project(":datasource"))
|
||||||
kover(project(":datastore"))
|
kover(project(":datastore"))
|
||||||
kover(project(":features:shopping_lists"))
|
kover(project(":features:shopping_lists"))
|
||||||
|
kover(project(":features:user_managment"))
|
||||||
kover(project(":logging"))
|
kover(project(":logging"))
|
||||||
kover(project(":model_mapper"))
|
kover(project(":model_mapper"))
|
||||||
kover(project(":ui"))
|
kover(project(":ui"))
|
||||||
|
|||||||
BIN
app/release/baselineProfiles/0/app-release.dm
Normal file
BIN
app/release/baselineProfiles/1/app-release.dm
Normal file
BIN
app/release/com.atridad.mealient_0.5.1.apk
Normal file
37
app/release/output-metadata.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"version": 3,
|
||||||
|
"artifactType": {
|
||||||
|
"type": "APK",
|
||||||
|
"kind": "Directory"
|
||||||
|
},
|
||||||
|
"applicationId": "com.atridad.mealient",
|
||||||
|
"variantName": "release",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"type": "SINGLE",
|
||||||
|
"filters": [],
|
||||||
|
"attributes": [],
|
||||||
|
"versionCode": 38,
|
||||||
|
"versionName": "0.5.1",
|
||||||
|
"outputFile": "app-release.apk"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"elementType": "File",
|
||||||
|
"baselineProfiles": [
|
||||||
|
{
|
||||||
|
"minApi": 28,
|
||||||
|
"maxApi": 30,
|
||||||
|
"baselineProfiles": [
|
||||||
|
"baselineProfiles/1/app-release.dm"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"minApi": 31,
|
||||||
|
"maxApi": 2147483647,
|
||||||
|
"baselineProfiles": [
|
||||||
|
"baselineProfiles/0/app-release.dm"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"minSdkVersionForDexing": 26
|
||||||
|
}
|
||||||
@@ -11,9 +11,11 @@ import com.atridad.mealient.datasource.models.ParseRecipeURLRequest
|
|||||||
import com.atridad.mealient.model_mapper.ModelMapper
|
import com.atridad.mealient.model_mapper.ModelMapper
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class MealieDataSourceWrapper @Inject constructor(
|
class MealieDataSourceWrapper
|
||||||
private val dataSource: MealieDataSource,
|
@Inject
|
||||||
private val modelMapper: ModelMapper,
|
constructor(
|
||||||
|
private val dataSource: MealieDataSource,
|
||||||
|
private val modelMapper: ModelMapper,
|
||||||
) : AddRecipeDataSource, RecipeDataSource, ParseRecipeDataSource {
|
) : AddRecipeDataSource, RecipeDataSource, ParseRecipeDataSource {
|
||||||
|
|
||||||
override suspend fun addRecipe(recipe: AddRecipeInfo): String {
|
override suspend fun addRecipe(recipe: AddRecipeInfo): String {
|
||||||
@@ -23,10 +25,11 @@ class MealieDataSourceWrapper @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun requestRecipes(
|
override suspend fun requestRecipes(
|
||||||
start: Int,
|
start: Int,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
): List<GetRecipeSummaryResponse> {
|
): List<GetRecipeSummaryResponse> {
|
||||||
// Imagine start is 30 and limit is 15. It means that we already have page 1 and 2, now we need page 3
|
// Imagine start is 30 and limit is 15. It means that we already have page 1 and 2, now we
|
||||||
|
// need page 3
|
||||||
val page = start / limit + 1
|
val page = start / limit + 1
|
||||||
return dataSource.requestRecipes(page, limit)
|
return dataSource.requestRecipes(page, limit)
|
||||||
}
|
}
|
||||||
@@ -40,11 +43,31 @@ class MealieDataSourceWrapper @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getFavoriteRecipes(): List<String> {
|
override suspend fun getFavoriteRecipes(): List<String> {
|
||||||
return dataSource.requestUserInfo().favoriteRecipes
|
val userInfo = dataSource.requestUserInfo()
|
||||||
|
|
||||||
|
// Use the correct favorites endpoint that actually works
|
||||||
|
return try {
|
||||||
|
val favoritesResponse = dataSource.getUserFavoritesAlternative(userInfo.id)
|
||||||
|
val favoriteRecipeIds =
|
||||||
|
favoritesResponse.ratings.filter { it.isFavorite }.map { it.recipeId }
|
||||||
|
|
||||||
|
// Get all recipes to create UUID-to-slug mapping
|
||||||
|
val allRecipes = dataSource.requestRecipes(1, -1) // Get all recipes
|
||||||
|
val uuidToSlugMap = allRecipes.associate { it.remoteId to it.slug }
|
||||||
|
|
||||||
|
// Map favorite UUIDs to slugs
|
||||||
|
val favoriteSlugs = favoriteRecipeIds.mapNotNull { uuid -> uuidToSlugMap[uuid] }
|
||||||
|
|
||||||
|
favoriteSlugs
|
||||||
|
} catch (e: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateIsRecipeFavorite(recipeSlug: String, isFavorite: Boolean) {
|
override suspend fun updateIsRecipeFavorite(recipeSlug: String, isFavorite: Boolean) {
|
||||||
val userId = dataSource.requestUserInfo().id
|
val userInfo = dataSource.requestUserInfo()
|
||||||
|
val userId = userInfo.id
|
||||||
|
|
||||||
if (isFavorite) {
|
if (isFavorite) {
|
||||||
dataSource.addFavoriteRecipe(userId, recipeSlug)
|
dataSource.addFavoriteRecipe(userId, recipeSlug)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -11,30 +11,34 @@ import com.atridad.mealient.database.recipe.entity.RecipeSummaryEntity
|
|||||||
import com.atridad.mealient.datasource.runCatchingExceptCancel
|
import com.atridad.mealient.datasource.runCatchingExceptCancel
|
||||||
import com.atridad.mealient.logging.Logger
|
import com.atridad.mealient.logging.Logger
|
||||||
import com.atridad.mealient.model_mapper.ModelMapper
|
import com.atridad.mealient.model_mapper.ModelMapper
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@OptIn(ExperimentalPagingApi::class)
|
@OptIn(ExperimentalPagingApi::class)
|
||||||
@Singleton
|
@Singleton
|
||||||
class RecipesRemoteMediator @Inject constructor(
|
class RecipesRemoteMediator
|
||||||
private val storage: RecipeStorage,
|
@Inject
|
||||||
private val network: RecipeDataSource,
|
constructor(
|
||||||
private val pagingSourceFactory: RecipePagingSourceFactory,
|
private val storage: RecipeStorage,
|
||||||
private val logger: Logger,
|
private val network: RecipeDataSource,
|
||||||
private val modelMapper: ModelMapper,
|
private val pagingSourceFactory: RecipePagingSourceFactory,
|
||||||
private val dispatchers: AppDispatchers,
|
private val logger: Logger,
|
||||||
|
private val modelMapper: ModelMapper,
|
||||||
|
private val dispatchers: AppDispatchers,
|
||||||
) : RemoteMediator<Int, RecipeSummaryEntity>() {
|
) : RemoteMediator<Int, RecipeSummaryEntity>() {
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting var lastRequestEnd: Int = 0
|
||||||
var lastRequestEnd: Int = 0
|
|
||||||
|
|
||||||
override suspend fun load(
|
override suspend fun load(
|
||||||
loadType: LoadType, state: PagingState<Int, RecipeSummaryEntity>
|
loadType: LoadType,
|
||||||
|
state: PagingState<Int, RecipeSummaryEntity>
|
||||||
): MediatorResult {
|
): MediatorResult {
|
||||||
logger.v { "load() called with: lastRequestEnd = $lastRequestEnd, loadType = $loadType, state = $state" }
|
logger.v {
|
||||||
|
"load() called with: lastRequestEnd = $lastRequestEnd, loadType = $loadType, state = $state"
|
||||||
|
}
|
||||||
|
|
||||||
if (loadType == PREPEND) {
|
if (loadType == PREPEND) {
|
||||||
logger.i { "load: early exit, PREPEND isn't supported" }
|
logger.i { "load: early exit, PREPEND isn't supported" }
|
||||||
@@ -44,17 +48,17 @@ class RecipesRemoteMediator @Inject constructor(
|
|||||||
val start = if (loadType == REFRESH) 0 else lastRequestEnd
|
val start = if (loadType == REFRESH) 0 else lastRequestEnd
|
||||||
val limit = if (loadType == REFRESH) state.config.initialLoadSize else state.config.pageSize
|
val limit = if (loadType == REFRESH) state.config.initialLoadSize else state.config.pageSize
|
||||||
|
|
||||||
val count: Int = runCatchingExceptCancel {
|
val count: Int =
|
||||||
updateRecipes(start, limit, loadType)
|
runCatchingExceptCancel { updateRecipes(start, limit, loadType) }.getOrElse {
|
||||||
}.getOrElse {
|
logger.e(it) { "load: can't load recipes" }
|
||||||
logger.e(it) { "load: can't load recipes" }
|
return MediatorResult.Error(it)
|
||||||
return MediatorResult.Error(it)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// After something is inserted into DB the paging sources have to be invalidated
|
// After something is inserted into DB the paging sources have to be invalidated
|
||||||
// But for some reason Room/Paging library don't do it automatically
|
// But for some reason Room/Paging library don't do it automatically
|
||||||
// Here we invalidate them manually.
|
// Here we invalidate them manually.
|
||||||
// Read that trick here https://github.com/android/architecture-components-samples/issues/889#issuecomment-880847858
|
// Read that trick here
|
||||||
|
// https://github.com/android/architecture-components-samples/issues/889#issuecomment-880847858
|
||||||
pagingSourceFactory.invalidate()
|
pagingSourceFactory.invalidate()
|
||||||
|
|
||||||
logger.d { "load: expectedCount = $limit, received $count" }
|
logger.d { "load: expectedCount = $limit, received $count" }
|
||||||
@@ -63,25 +67,30 @@ class RecipesRemoteMediator @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateRecipes(
|
suspend fun updateRecipes(
|
||||||
start: Int,
|
start: Int,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
loadType: LoadType = REFRESH,
|
loadType: LoadType = REFRESH,
|
||||||
): Int = coroutineScope {
|
): Int = coroutineScope {
|
||||||
logger.v { "updateRecipes() called with: start = $start, limit = $limit, loadType = $loadType" }
|
logger.v {
|
||||||
val deferredRecipes = async { network.requestRecipes(start, limit) }
|
"updateRecipes() called with: start = $start, limit = $limit, loadType = $loadType"
|
||||||
val favorites = runCatchingExceptCancel {
|
|
||||||
network.getFavoriteRecipes()
|
|
||||||
}.getOrDefault(emptyList()).toHashSet()
|
|
||||||
val recipes = deferredRecipes.await()
|
|
||||||
val entities = withContext(dispatchers.default) {
|
|
||||||
recipes.map { recipe ->
|
|
||||||
val isFavorite = favorites.contains(recipe.slug)
|
|
||||||
modelMapper.toRecipeSummaryEntity(recipe, isFavorite)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (loadType == REFRESH) storage.refreshAll(entities)
|
val deferredRecipes = async { network.requestRecipes(start, limit) }
|
||||||
else storage.saveRecipes(entities)
|
val favorites =
|
||||||
|
runCatchingExceptCancel { network.getFavoriteRecipes() }
|
||||||
|
.getOrDefault(emptyList())
|
||||||
|
.toHashSet()
|
||||||
|
|
||||||
|
val recipes = deferredRecipes.await()
|
||||||
|
|
||||||
|
val entities =
|
||||||
|
withContext(dispatchers.default) {
|
||||||
|
recipes.map { recipe ->
|
||||||
|
val isFavorite = favorites.contains(recipe.slug)
|
||||||
|
modelMapper.toRecipeSummaryEntity(recipe, isFavorite)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadType == REFRESH) storage.refreshAll(entities) else storage.saveRecipes(entities)
|
||||||
recipes.size
|
recipes.size
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,8 @@ interface PreferencesStorage {
|
|||||||
|
|
||||||
val lastExecutedMigrationVersionKey: Preferences.Key<Int>
|
val lastExecutedMigrationVersionKey: Preferences.Key<Int>
|
||||||
|
|
||||||
|
val themeModeKey: Preferences.Key<String>
|
||||||
|
|
||||||
suspend fun <T> getValue(key: Preferences.Key<T>): T?
|
suspend fun <T> getValue(key: Preferences.Key<T>): T?
|
||||||
|
|
||||||
suspend fun <T> requireValue(key: Preferences.Key<T>): T
|
suspend fun <T> requireValue(key: Preferences.Key<T>): T
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ class PreferencesStorageImpl @Inject constructor(
|
|||||||
override val lastExecutedMigrationVersionKey: Preferences.Key<Int> =
|
override val lastExecutedMigrationVersionKey: Preferences.Key<Int> =
|
||||||
intPreferencesKey("lastExecutedMigrationVersion")
|
intPreferencesKey("lastExecutedMigrationVersion")
|
||||||
|
|
||||||
|
override val themeModeKey: Preferences.Key<String> =
|
||||||
|
stringPreferencesKey("themeMode")
|
||||||
|
|
||||||
override suspend fun <T> getValue(key: Preferences.Key<T>): T? {
|
override suspend fun <T> getValue(key: Preferences.Key<T>): T? {
|
||||||
val value = dataStore.data.first()[key]
|
val value = dataStore.data.first()[key]
|
||||||
logger.v { "getValue() returned: $value for $key" }
|
logger.v { "getValue() returned: $value for $key" }
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import com.atridad.mealient.ui.destinations.BaseURLScreenDestination
|
|||||||
import com.atridad.mealient.ui.destinations.DisclaimerScreenDestination
|
import com.atridad.mealient.ui.destinations.DisclaimerScreenDestination
|
||||||
import com.atridad.mealient.ui.destinations.RecipeScreenDestination
|
import com.atridad.mealient.ui.destinations.RecipeScreenDestination
|
||||||
import com.atridad.mealient.ui.destinations.RecipesListDestination
|
import com.atridad.mealient.ui.destinations.RecipesListDestination
|
||||||
|
import com.atridad.mealient.ui.destinations.SettingsScreenDestination
|
||||||
|
import com.mealient.user_management.ui.profile.destinations.UserProfileScreenDestination
|
||||||
|
|
||||||
internal object NavGraphs {
|
internal object NavGraphs {
|
||||||
|
|
||||||
@@ -40,6 +42,8 @@ internal object NavGraphs {
|
|||||||
DisclaimerScreenDestination,
|
DisclaimerScreenDestination,
|
||||||
BaseURLScreenDestination,
|
BaseURLScreenDestination,
|
||||||
AuthenticationScreenDestination,
|
AuthenticationScreenDestination,
|
||||||
|
SettingsScreenDestination,
|
||||||
|
UserProfileScreenDestination,
|
||||||
),
|
),
|
||||||
nestedNavGraphs = listOf(
|
nestedNavGraphs = listOf(
|
||||||
recipes,
|
recipes,
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import androidx.annotation.StringRes
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Email
|
import androidx.compose.material.icons.filled.Email
|
||||||
import androidx.compose.material.icons.filled.List
|
import androidx.compose.material.icons.automirrored.filled.List
|
||||||
import androidx.compose.material.icons.filled.Logout
|
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
import androidx.compose.material.icons.filled.ShoppingCart
|
import androidx.compose.material.icons.filled.ShoppingCart
|
||||||
import androidx.compose.material.icons.filled.SyncAlt
|
import androidx.compose.material.icons.filled.SyncAlt
|
||||||
import androidx.compose.material3.DrawerState
|
import androidx.compose.material3.DrawerState
|
||||||
@@ -28,6 +29,7 @@ import com.atridad.mealient.ui.components.DrawerItem
|
|||||||
import com.atridad.mealient.ui.destinations.AddRecipeScreenDestination
|
import com.atridad.mealient.ui.destinations.AddRecipeScreenDestination
|
||||||
import com.atridad.mealient.ui.destinations.BaseURLScreenDestination
|
import com.atridad.mealient.ui.destinations.BaseURLScreenDestination
|
||||||
import com.atridad.mealient.ui.destinations.RecipesListDestination
|
import com.atridad.mealient.ui.destinations.RecipesListDestination
|
||||||
|
import com.mealient.user_management.ui.profile.destinations.UserProfileScreenDestination
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -78,7 +80,7 @@ internal fun createDrawerItems(
|
|||||||
return listOf(
|
return listOf(
|
||||||
createNavigationItem(
|
createNavigationItem(
|
||||||
nameRes = R.string.menu_navigation_drawer_recipes_list,
|
nameRes = R.string.menu_navigation_drawer_recipes_list,
|
||||||
icon = Icons.Default.List,
|
icon = Icons.AutoMirrored.Filled.List,
|
||||||
direction = RecipesListDestination,
|
direction = RecipesListDestination,
|
||||||
),
|
),
|
||||||
createNavigationItem(
|
createNavigationItem(
|
||||||
@@ -91,6 +93,11 @@ internal fun createDrawerItems(
|
|||||||
icon = Icons.Default.ShoppingCart,
|
icon = Icons.Default.ShoppingCart,
|
||||||
direction = NavGraphs.shoppingLists,
|
direction = NavGraphs.shoppingLists,
|
||||||
),
|
),
|
||||||
|
createNavigationItem(
|
||||||
|
nameRes = R.string.menu_navigation_drawer_profile,
|
||||||
|
icon = Icons.Default.Person,
|
||||||
|
direction = UserProfileScreenDestination,
|
||||||
|
),
|
||||||
createNavigationItem(
|
createNavigationItem(
|
||||||
nameRes = R.string.menu_navigation_drawer_change_url,
|
nameRes = R.string.menu_navigation_drawer_change_url,
|
||||||
icon = Icons.Default.SyncAlt,
|
icon = Icons.Default.SyncAlt,
|
||||||
@@ -98,7 +105,7 @@ internal fun createDrawerItems(
|
|||||||
),
|
),
|
||||||
createActionItem(
|
createActionItem(
|
||||||
nameRes = R.string.menu_navigation_drawer_logout,
|
nameRes = R.string.menu_navigation_drawer_logout,
|
||||||
icon = Icons.Default.Logout,
|
icon = Icons.AutoMirrored.Filled.Logout,
|
||||||
appEvent = AppEvent.Logout,
|
appEvent = AppEvent.Logout,
|
||||||
),
|
),
|
||||||
createActionItem(
|
createActionItem(
|
||||||
|
|||||||
@@ -4,12 +4,16 @@ import android.os.Bundle
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import androidx.core.view.WindowInsetsControllerCompat
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import com.atridad.mealient.extensions.isDarkThemeOn
|
import com.atridad.mealient.extensions.isDarkThemeOn
|
||||||
import com.atridad.mealient.logging.Logger
|
import com.atridad.mealient.logging.Logger
|
||||||
import com.atridad.mealient.ui.AppTheme
|
import com.atridad.mealient.ui.theme.MealientTheme
|
||||||
|
import com.atridad.mealient.data.storage.PreferencesStorage
|
||||||
|
import com.atridad.mealient.ui.settings.ThemeMode
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -20,20 +24,33 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
private val viewModel by viewModels<MainActivityViewModel>()
|
private val viewModel by viewModels<MainActivityViewModel>()
|
||||||
|
|
||||||
|
@Inject lateinit var prefs: PreferencesStorage
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
val splashScreen = installSplashScreen()
|
val splashScreen = installSplashScreen()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
logger.v { "onCreate() called with: savedInstanceState = $savedInstanceState" }
|
logger.v { "onCreate() called with: savedInstanceState = $savedInstanceState" }
|
||||||
|
// Status bar appearance is now handled by the Material 3 theme
|
||||||
|
// Navigation bar appearance can still be set here if needed
|
||||||
with(WindowInsetsControllerCompat(window, window.decorView)) {
|
with(WindowInsetsControllerCompat(window, window.decorView)) {
|
||||||
val isAppearanceLightBars = !isDarkThemeOn()
|
val isAppearanceLightBars = !isDarkThemeOn()
|
||||||
isAppearanceLightNavigationBars = isAppearanceLightBars
|
isAppearanceLightNavigationBars = isAppearanceLightBars
|
||||||
isAppearanceLightStatusBars = isAppearanceLightBars
|
|
||||||
}
|
}
|
||||||
splashScreen.setKeepOnScreenCondition {
|
splashScreen.setKeepOnScreenCondition {
|
||||||
viewModel.appState.value.forcedRoute == ForcedDestination.Undefined
|
viewModel.appState.value.forcedRoute == ForcedDestination.Undefined
|
||||||
}
|
}
|
||||||
setContent {
|
setContent {
|
||||||
AppTheme {
|
// Observe theme changes live from preferences
|
||||||
|
val initialMode = runBlocking { prefs.getValue(prefs.themeModeKey) } ?: ThemeMode.DEVICE.name
|
||||||
|
val themeName = prefs.valueUpdates(prefs.themeModeKey)
|
||||||
|
.collectAsState(initial = initialMode).value ?: initialMode
|
||||||
|
val selectedMode = runCatching { ThemeMode.valueOf(themeName) }.getOrDefault(ThemeMode.DEVICE)
|
||||||
|
val dark = when (selectedMode) {
|
||||||
|
ThemeMode.DEVICE -> androidx.compose.foundation.isSystemInDarkTheme()
|
||||||
|
ThemeMode.LIGHT -> false
|
||||||
|
ThemeMode.DARK -> true
|
||||||
|
}
|
||||||
|
MealientTheme(darkTheme = dark) {
|
||||||
MealientApp(viewModel)
|
MealientApp(viewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,36 @@
|
|||||||
package com.atridad.mealient.ui.activity
|
package com.atridad.mealient.ui.activity
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.List
|
||||||
|
import androidx.compose.material.icons.filled.ShoppingCart
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.filled.ShoppingCart
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.BottomAppBar
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.NavigationBarItem
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.surfaceColorAtElevation
|
||||||
|
import androidx.compose.ui.graphics.luminance
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import com.ramcosta.composedestinations.DestinationsNavHost
|
import com.ramcosta.composedestinations.DestinationsNavHost
|
||||||
import com.ramcosta.composedestinations.animations.defaults.RootNavGraphDefaultAnimations
|
import com.ramcosta.composedestinations.animations.defaults.RootNavGraphDefaultAnimations
|
||||||
@@ -21,8 +42,15 @@ import com.ramcosta.composedestinations.spec.DestinationSpec
|
|||||||
import com.ramcosta.composedestinations.spec.NavHostEngine
|
import com.ramcosta.composedestinations.spec.NavHostEngine
|
||||||
import com.ramcosta.composedestinations.spec.Route
|
import com.ramcosta.composedestinations.spec.Route
|
||||||
import com.ramcosta.composedestinations.utils.currentDestinationAsState
|
import com.ramcosta.composedestinations.utils.currentDestinationAsState
|
||||||
|
import com.atridad.mealient.R
|
||||||
import com.atridad.mealient.ui.NavGraphs
|
import com.atridad.mealient.ui.NavGraphs
|
||||||
import com.atridad.mealient.ui.components.rememberBaseScreenState
|
import com.atridad.mealient.ui.destinations.RecipesListDestination
|
||||||
|
import com.atridad.mealient.ui.destinations.AddRecipeScreenDestination
|
||||||
|
import com.atridad.mealient.ui.destinations.SettingsScreenDestination
|
||||||
|
import com.atridad.mealient.shopping_lists.ui.destinations.ShoppingListsScreenDestination
|
||||||
|
import com.mealient.user_management.ui.profile.destinations.UserProfileScreenDestination
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun MealientApp(
|
internal fun MealientApp(
|
||||||
@@ -49,6 +77,22 @@ private fun MealientApp(
|
|||||||
val currentDestinationState = controller.currentDestinationAsState()
|
val currentDestinationState = controller.currentDestinationAsState()
|
||||||
val currentDestination = currentDestinationState.value
|
val currentDestination = currentDestinationState.value
|
||||||
|
|
||||||
|
// Ensure system bars match app colors
|
||||||
|
val view = LocalView.current
|
||||||
|
val barsColor = androidx.compose.material3.MaterialTheme.colorScheme.surface
|
||||||
|
// Match Android navigation bar to the BottomAppBar's elevated container color
|
||||||
|
val bottomBarColor = androidx.compose.material3.MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
|
||||||
|
// Decide icon appearance from actual nav bar color brightness to match app-selected theme
|
||||||
|
val lightBars = bottomBarColor.luminance() > 0.5f
|
||||||
|
androidx.compose.runtime.SideEffect {
|
||||||
|
val window = (view.context as android.app.Activity).window
|
||||||
|
window.navigationBarColor = bottomBarColor.toArgb()
|
||||||
|
window.statusBarColor = barsColor.toArgb()
|
||||||
|
val controller = WindowCompat.getInsetsController(window, view)
|
||||||
|
controller.isAppearanceLightNavigationBars = lightBars
|
||||||
|
controller.isAppearanceLightStatusBars = lightBars
|
||||||
|
}
|
||||||
|
|
||||||
ForceNavigationEffect(
|
ForceNavigationEffect(
|
||||||
currentDestination = currentDestination,
|
currentDestination = currentDestination,
|
||||||
controller = controller,
|
controller = controller,
|
||||||
@@ -94,38 +138,48 @@ private fun MealientDialog(
|
|||||||
dialogState: DialogState,
|
dialogState: DialogState,
|
||||||
onEvent: (AppEvent) -> Unit,
|
onEvent: (AppEvent) -> Unit,
|
||||||
) {
|
) {
|
||||||
AlertDialog(
|
androidx.compose.material3.AlertDialog(
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
onEvent(dialogState.onDismiss)
|
onEvent(dialogState.onDismiss)
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(
|
androidx.compose.material3.TextButton(
|
||||||
onClick = { onEvent(dialogState.onPositiveClick) },
|
onClick = { onEvent(dialogState.onPositiveClick) },
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = dialogState.positiveButton),
|
text = stringResource(id = dialogState.positiveButton),
|
||||||
|
style = androidx.compose.material3.MaterialTheme.typography.labelLarge
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(
|
androidx.compose.material3.TextButton(
|
||||||
onClick = { onEvent(dialogState.onNegativeClick) },
|
onClick = { onEvent(dialogState.onNegativeClick) },
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = dialogState.negativeButton),
|
text = stringResource(id = dialogState.negativeButton),
|
||||||
|
style = androidx.compose.material3.MaterialTheme.typography.labelLarge
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = dialogState.title),
|
text = stringResource(id = dialogState.title),
|
||||||
|
style = androidx.compose.material3.MaterialTheme.typography.headlineSmall,
|
||||||
|
color = androidx.compose.material3.MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
text = {
|
text = {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = dialogState.message),
|
text = stringResource(id = dialogState.message),
|
||||||
|
style = androidx.compose.material3.MaterialTheme.typography.bodyMedium,
|
||||||
|
color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
containerColor = androidx.compose.material3.MaterialTheme.colorScheme.surface,
|
||||||
|
titleContentColor = androidx.compose.material3.MaterialTheme.colorScheme.onSurface,
|
||||||
|
textContentColor = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
shape = androidx.compose.foundation.shape.RoundedCornerShape(28.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,22 +211,89 @@ private fun AppContent(
|
|||||||
startRoute: Route?,
|
startRoute: Route?,
|
||||||
onEvent: (AppEvent) -> Unit,
|
onEvent: (AppEvent) -> Unit,
|
||||||
) {
|
) {
|
||||||
val drawerItems = createDrawerItems(
|
val currentDestination by controller.currentDestinationAsState()
|
||||||
navController = controller,
|
val view = LocalView.current
|
||||||
onEvent = onEvent,
|
val bottomBarColor = androidx.compose.material3.MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
|
||||||
)
|
val lightNavIcons = bottomBarColor.luminance() > 0.5f
|
||||||
val baseScreenState = rememberBaseScreenState(
|
androidx.compose.runtime.SideEffect {
|
||||||
drawerItems = drawerItems,
|
val window = (view.context as android.app.Activity).window
|
||||||
)
|
window.navigationBarColor = bottomBarColor.toArgb()
|
||||||
|
val controllerInsets = WindowCompat.getInsetsController(window, view)
|
||||||
|
controllerInsets.isAppearanceLightNavigationBars = lightNavIcons
|
||||||
|
}
|
||||||
|
|
||||||
DestinationsNavHost(
|
Scaffold(
|
||||||
navGraph = NavGraphs.root,
|
contentWindowInsets = WindowInsets(0.dp),
|
||||||
engine = engine,
|
bottomBar = {
|
||||||
navController = controller,
|
BottomAppBar(
|
||||||
startRoute = startRoute ?: NavGraphs.root.startRoute,
|
windowInsets = WindowInsets(0.dp),
|
||||||
dependenciesContainerBuilder = {
|
containerColor = bottomBarColor,
|
||||||
dependency(baseScreenState)
|
actions = {
|
||||||
|
NavigationBarItem(
|
||||||
|
icon = { Icon(Icons.AutoMirrored.Filled.List, contentDescription = "Recipes") },
|
||||||
|
label = { Text(stringResource(R.string.menu_navigation_drawer_recipes_list)) },
|
||||||
|
selected = currentDestination?.route == RecipesListDestination.route,
|
||||||
|
onClick = {
|
||||||
|
controller.navigate(RecipesListDestination) {
|
||||||
|
popUpTo(controller.graph.startDestinationId) {
|
||||||
|
inclusive = false
|
||||||
|
}
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
NavigationBarItem(
|
||||||
|
icon = { Icon(Icons.Default.ShoppingCart, contentDescription = "Shopping Lists") },
|
||||||
|
label = { Text(stringResource(R.string.menu_navigation_drawer_shopping_lists)) },
|
||||||
|
selected = currentDestination?.route == ShoppingListsScreenDestination.route,
|
||||||
|
onClick = {
|
||||||
|
controller.navigate(ShoppingListsScreenDestination) {
|
||||||
|
popUpTo(controller.graph.startDestinationId) {
|
||||||
|
inclusive = false
|
||||||
|
}
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
NavigationBarItem(
|
||||||
|
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
|
||||||
|
label = { Text("Settings") },
|
||||||
|
selected = currentDestination?.route == SettingsScreenDestination.route,
|
||||||
|
onClick = {
|
||||||
|
controller.navigate(SettingsScreenDestination) {
|
||||||
|
popUpTo(controller.graph.startDestinationId) {
|
||||||
|
inclusive = false
|
||||||
|
}
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
NavigationBarItem(
|
||||||
|
icon = { Icon(Icons.Default.Person, contentDescription = "Profile") },
|
||||||
|
label = { Text(stringResource(R.string.menu_navigation_drawer_profile)) },
|
||||||
|
selected = currentDestination?.route == UserProfileScreenDestination.route,
|
||||||
|
onClick = {
|
||||||
|
controller.navigate(UserProfileScreenDestination) {
|
||||||
|
popUpTo(controller.graph.startDestinationId) {
|
||||||
|
inclusive = false
|
||||||
|
}
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
) { paddingValues ->
|
||||||
|
DestinationsNavHost(
|
||||||
|
navGraph = NavGraphs.root,
|
||||||
|
engine = engine,
|
||||||
|
navController = controller,
|
||||||
|
startRoute = startRoute ?: NavGraphs.root.startRoute,
|
||||||
|
modifier = Modifier.padding(paddingValues)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
@@ -32,22 +33,17 @@ import com.ramcosta.composedestinations.annotation.Destination
|
|||||||
import com.atridad.mealient.R
|
import com.atridad.mealient.R
|
||||||
import com.atridad.mealient.ui.AppTheme
|
import com.atridad.mealient.ui.AppTheme
|
||||||
import com.atridad.mealient.ui.Dimens
|
import com.atridad.mealient.ui.Dimens
|
||||||
import com.atridad.mealient.ui.components.BaseScreenState
|
|
||||||
import com.atridad.mealient.ui.components.BaseScreenWithNavigation
|
|
||||||
import com.atridad.mealient.ui.components.TopProgressIndicator
|
import com.atridad.mealient.ui.components.TopProgressIndicator
|
||||||
import com.atridad.mealient.ui.components.previewBaseScreenState
|
|
||||||
import com.atridad.mealient.ui.preview.ColorSchemePreview
|
import com.atridad.mealient.ui.preview.ColorSchemePreview
|
||||||
|
|
||||||
@Destination
|
@Destination
|
||||||
@Composable
|
@Composable
|
||||||
internal fun AddRecipeScreen(
|
internal fun AddRecipeScreen(
|
||||||
baseScreenState: BaseScreenState,
|
|
||||||
viewModel: AddRecipeViewModel = hiltViewModel()
|
viewModel: AddRecipeViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
val screenState by viewModel.screenState.collectAsState()
|
val screenState by viewModel.screenState.collectAsState()
|
||||||
|
|
||||||
AddRecipeScreen(
|
AddRecipeScreen(
|
||||||
baseScreenState = baseScreenState,
|
|
||||||
state = screenState,
|
state = screenState,
|
||||||
onEvent = viewModel::onEvent,
|
onEvent = viewModel::onEvent,
|
||||||
)
|
)
|
||||||
@@ -55,7 +51,6 @@ internal fun AddRecipeScreen(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun AddRecipeScreen(
|
internal fun AddRecipeScreen(
|
||||||
baseScreenState: BaseScreenState,
|
|
||||||
state: AddRecipeScreenState,
|
state: AddRecipeScreenState,
|
||||||
onEvent: (AddRecipeScreenEvent) -> Unit,
|
onEvent: (AddRecipeScreenEvent) -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -74,19 +69,14 @@ internal fun AddRecipeScreen(
|
|||||||
snackbarHostState.currentSnackbarData?.dismiss()
|
snackbarHostState.currentSnackbarData?.dismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
BaseScreenWithNavigation(
|
TopProgressIndicator(
|
||||||
baseScreenState = baseScreenState,
|
modifier = Modifier.fillMaxSize(),
|
||||||
snackbarHostState = snackbarHostState,
|
isLoading = state.isLoading,
|
||||||
) { modifier ->
|
) {
|
||||||
TopProgressIndicator(
|
AddRecipeScreenContent(
|
||||||
modifier = modifier,
|
state = state,
|
||||||
isLoading = state.isLoading,
|
onEvent = onEvent,
|
||||||
) {
|
)
|
||||||
AddRecipeScreenContent(
|
|
||||||
state = state,
|
|
||||||
onEvent = onEvent,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +294,6 @@ private fun AddRecipeInputField(
|
|||||||
private fun AddRecipeScreenPreview() {
|
private fun AddRecipeScreenPreview() {
|
||||||
AppTheme {
|
AppTheme {
|
||||||
AddRecipeScreen(
|
AddRecipeScreen(
|
||||||
baseScreenState = previewBaseScreenState(),
|
|
||||||
state = AddRecipeScreenState(),
|
state = AddRecipeScreenState(),
|
||||||
onEvent = {},
|
onEvent = {},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,18 +30,13 @@ import com.ramcosta.composedestinations.annotation.Destination
|
|||||||
import com.atridad.mealient.R
|
import com.atridad.mealient.R
|
||||||
import com.atridad.mealient.ui.AppTheme
|
import com.atridad.mealient.ui.AppTheme
|
||||||
import com.atridad.mealient.ui.Dimens
|
import com.atridad.mealient.ui.Dimens
|
||||||
import com.atridad.mealient.ui.components.BaseScreen
|
|
||||||
import com.atridad.mealient.ui.components.BaseScreenState
|
|
||||||
import com.atridad.mealient.ui.components.BaseScreenWithNavigation
|
|
||||||
import com.atridad.mealient.ui.components.TopProgressIndicator
|
import com.atridad.mealient.ui.components.TopProgressIndicator
|
||||||
import com.atridad.mealient.ui.components.previewBaseScreenState
|
|
||||||
import com.atridad.mealient.ui.preview.ColorSchemePreview
|
import com.atridad.mealient.ui.preview.ColorSchemePreview
|
||||||
|
|
||||||
@Destination
|
@Destination
|
||||||
@Composable
|
@Composable
|
||||||
internal fun BaseURLScreen(
|
internal fun BaseURLScreen(
|
||||||
navController: NavController,
|
navController: NavController,
|
||||||
baseScreenState: BaseScreenState,
|
|
||||||
viewModel: BaseURLViewModel = hiltViewModel(),
|
viewModel: BaseURLViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val screenState by viewModel.screenState.collectAsState()
|
val screenState by viewModel.screenState.collectAsState()
|
||||||
@@ -54,7 +49,6 @@ internal fun BaseURLScreen(
|
|||||||
|
|
||||||
BaseURLScreen(
|
BaseURLScreen(
|
||||||
state = screenState,
|
state = screenState,
|
||||||
baseScreenState = baseScreenState,
|
|
||||||
onEvent = viewModel::onEvent,
|
onEvent = viewModel::onEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -62,27 +56,13 @@ internal fun BaseURLScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun BaseURLScreen(
|
private fun BaseURLScreen(
|
||||||
state: BaseURLScreenState,
|
state: BaseURLScreenState,
|
||||||
baseScreenState: BaseScreenState,
|
|
||||||
onEvent: (BaseURLScreenEvent) -> Unit,
|
onEvent: (BaseURLScreenEvent) -> Unit,
|
||||||
) {
|
) {
|
||||||
val content: @Composable (Modifier) -> Unit = {
|
BaseURLScreen(
|
||||||
BaseURLScreen(
|
modifier = Modifier.fillMaxSize(),
|
||||||
modifier = it,
|
state = state,
|
||||||
state = state,
|
onEvent = onEvent,
|
||||||
onEvent = onEvent,
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.isNavigationEnabled) {
|
|
||||||
BaseScreenWithNavigation(
|
|
||||||
baseScreenState = baseScreenState,
|
|
||||||
content = content,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
BaseScreen(
|
|
||||||
content = content,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -177,29 +157,7 @@ private fun UrlInputField(
|
|||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
imeAction = ImeAction.Done,
|
imeAction = ImeAction.Done,
|
||||||
),
|
),
|
||||||
keyboardActions = KeyboardActions(
|
|
||||||
onDone = {
|
|
||||||
defaultKeyboardAction(ImeAction.Done)
|
|
||||||
onEvent(BaseURLScreenEvent.OnProceedClick)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ColorSchemePreview
|
|
||||||
@Composable
|
|
||||||
private fun BaseURLScreenPreview() {
|
|
||||||
AppTheme {
|
|
||||||
BaseURLScreen(
|
|
||||||
state = BaseURLScreenState(
|
|
||||||
userInput = "https://www.google.com",
|
|
||||||
errorText = null,
|
|
||||||
isButtonEnabled = true,
|
|
||||||
isLoading = true,
|
|
||||||
isNavigationEnabled = false,
|
|
||||||
),
|
|
||||||
baseScreenState = previewBaseScreenState(),
|
|
||||||
onEvent = {},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,14 +4,25 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.ramcosta.composedestinations.annotation.Destination
|
import com.ramcosta.composedestinations.annotation.Destination
|
||||||
import com.atridad.mealient.ui.AppTheme
|
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||||
|
import com.atridad.mealient.ui.theme.MealientTheme
|
||||||
import com.atridad.mealient.ui.Dimens
|
import com.atridad.mealient.ui.Dimens
|
||||||
import com.atridad.mealient.ui.components.BaseScreen
|
import com.atridad.mealient.ui.components.BaseScreen
|
||||||
import com.atridad.mealient.ui.preview.ColorSchemePreview
|
import com.atridad.mealient.ui.preview.ColorSchemePreview
|
||||||
@@ -25,11 +36,19 @@ data class RecipeScreenArgs(
|
|||||||
)
|
)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun RecipeScreen(
|
internal fun RecipeScreen(
|
||||||
|
navigator: DestinationsNavigator,
|
||||||
viewModel: RecipeInfoViewModel = hiltViewModel(),
|
viewModel: RecipeInfoViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsState()
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
BaseScreen { modifier ->
|
BaseScreen(
|
||||||
|
topAppBar = {
|
||||||
|
RecipeTopAppBar(
|
||||||
|
title = state.title ?: "Recipe",
|
||||||
|
onNavigateBack = { navigator.navigateUp() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { modifier ->
|
||||||
RecipeScreen(
|
RecipeScreen(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
state = state,
|
state = state,
|
||||||
@@ -37,6 +56,39 @@ internal fun RecipeScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun RecipeTopAppBar(
|
||||||
|
title: String,
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = "Navigate back",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
navigationIconContentColor = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RecipeScreen(
|
private fun RecipeScreen(
|
||||||
state: RecipeInfoUiState,
|
state: RecipeInfoUiState,
|
||||||
@@ -74,7 +126,7 @@ private fun RecipeScreen(
|
|||||||
@ColorSchemePreview
|
@ColorSchemePreview
|
||||||
@Composable
|
@Composable
|
||||||
private fun RecipeScreenPreview() {
|
private fun RecipeScreenPreview() {
|
||||||
AppTheme {
|
MealientTheme {
|
||||||
RecipeScreen(
|
RecipeScreen(
|
||||||
state = RecipeInfoUiState(
|
state = RecipeInfoUiState(
|
||||||
showIngredients = true,
|
showIngredients = true,
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package com.atridad.mealient.ui.recipes.list
|
|||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import com.atridad.mealient.R
|
import com.atridad.mealient.R
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -23,9 +25,13 @@ internal fun ConfirmDeleteDialog(
|
|||||||
onClick = {
|
onClick = {
|
||||||
onConfirm(item)
|
onConfirm(item)
|
||||||
},
|
},
|
||||||
|
colors = ButtonDefaults.textButtonColors(
|
||||||
|
contentColor = androidx.compose.material3.MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = R.string.fragment_recipes_delete_recipe_confirm_dialog_positive_btn),
|
text = stringResource(id = R.string.fragment_recipes_delete_recipe_confirm_dialog_positive_btn),
|
||||||
|
style = androidx.compose.material3.MaterialTheme.typography.labelLarge
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -35,12 +41,15 @@ internal fun ConfirmDeleteDialog(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = R.string.fragment_recipes_delete_recipe_confirm_dialog_negative_btn),
|
text = stringResource(id = R.string.fragment_recipes_delete_recipe_confirm_dialog_negative_btn),
|
||||||
|
style = androidx.compose.material3.MaterialTheme.typography.labelLarge
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = R.string.fragment_recipes_delete_recipe_confirm_dialog_title),
|
text = stringResource(id = R.string.fragment_recipes_delete_recipe_confirm_dialog_title),
|
||||||
|
style = androidx.compose.material3.MaterialTheme.typography.headlineSmall,
|
||||||
|
color = androidx.compose.material3.MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
text = {
|
text = {
|
||||||
@@ -49,7 +58,13 @@ internal fun ConfirmDeleteDialog(
|
|||||||
id = R.string.fragment_recipes_delete_recipe_confirm_dialog_message,
|
id = R.string.fragment_recipes_delete_recipe_confirm_dialog_message,
|
||||||
item.entity.name
|
item.entity.name
|
||||||
),
|
),
|
||||||
|
style = androidx.compose.material3.MaterialTheme.typography.bodyMedium,
|
||||||
|
color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
containerColor = androidx.compose.material3.MaterialTheme.colorScheme.surface,
|
||||||
|
titleContentColor = androidx.compose.material3.MaterialTheme.colorScheme.onSurface,
|
||||||
|
textContentColor = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
shape = androidx.compose.foundation.shape.RoundedCornerShape(28.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -26,8 +26,10 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import com.atridad.mealient.R
|
import com.atridad.mealient.R
|
||||||
import com.atridad.mealient.ui.AppTheme
|
import com.atridad.mealient.ui.theme.MealientTheme
|
||||||
import com.atridad.mealient.ui.Dimens
|
import com.atridad.mealient.ui.theme.Spacing
|
||||||
|
import com.atridad.mealient.ui.theme.BorderRadius
|
||||||
|
import com.atridad.mealient.ui.theme.ComponentSizing
|
||||||
import com.atridad.mealient.ui.preview.ColorSchemePreview
|
import com.atridad.mealient.ui.preview.ColorSchemePreview
|
||||||
import com.atridad.mealient.ui.recipes.info.SUMMARY_ENTITY
|
import com.atridad.mealient.ui.recipes.info.SUMMARY_ENTITY
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
@@ -42,15 +44,24 @@ internal fun RecipeItem(
|
|||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
shape = RoundedCornerShape(BorderRadius.md),
|
||||||
|
colors = androidx.compose.material3.CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
elevation = androidx.compose.material3.CardDefaults.cardElevation(
|
||||||
|
defaultElevation = ComponentSizing.cardElevation,
|
||||||
|
pressedElevation = ComponentSizing.cardElevationPressed,
|
||||||
|
focusedElevation = ComponentSizing.cardElevationHovered,
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.clickable(onClick = onItemClick)
|
.clickable(onClick = onItemClick)
|
||||||
.padding(
|
.padding(
|
||||||
horizontal = Dimens.Medium,
|
horizontal = Spacing.md,
|
||||||
vertical = Dimens.Small,
|
vertical = Spacing.sm,
|
||||||
),
|
),
|
||||||
verticalArrangement = Arrangement.spacedBy(Dimens.Small),
|
verticalArrangement = Arrangement.spacedBy(Spacing.sm),
|
||||||
) {
|
) {
|
||||||
RecipeHeader(
|
RecipeHeader(
|
||||||
onDeleteClick = onDeleteClick,
|
onDeleteClick = onDeleteClick,
|
||||||
@@ -66,7 +77,9 @@ internal fun RecipeItem(
|
|||||||
text = recipe.entity.name,
|
text = recipe.entity.name,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.padding(top = Spacing.xs)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,7 +95,7 @@ private fun RecipeImage(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.aspectRatio(2f) // 2:1
|
.aspectRatio(2f) // 2:1
|
||||||
.clip(RoundedCornerShape(Dimens.Intermediate)),
|
.clip(RoundedCornerShape(BorderRadius.md)),
|
||||||
model = recipe.imageUrl,
|
model = recipe.imageUrl,
|
||||||
contentDescription = stringResource(id = R.string.content_description_fragment_recipe_info_image),
|
contentDescription = stringResource(id = R.string.content_description_fragment_recipe_info_image),
|
||||||
placeholder = imageFallback,
|
placeholder = imageFallback,
|
||||||
@@ -105,6 +118,10 @@ private fun RecipeHeader(
|
|||||||
) {
|
) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onDeleteClick,
|
onClick = onDeleteClick,
|
||||||
|
colors = androidx.compose.material3.IconButtonDefaults.iconButtonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Delete,
|
imageVector = Icons.Default.Delete,
|
||||||
@@ -115,6 +132,18 @@ private fun RecipeHeader(
|
|||||||
if (recipe.showFavoriteIcon) {
|
if (recipe.showFavoriteIcon) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onFavoriteClick,
|
onClick = onFavoriteClick,
|
||||||
|
colors = androidx.compose.material3.IconButtonDefaults.iconButtonColors(
|
||||||
|
containerColor = if (recipe.entity.isFavorite) {
|
||||||
|
MaterialTheme.colorScheme.tertiaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
},
|
||||||
|
contentColor = if (recipe.entity.isFavorite) {
|
||||||
|
MaterialTheme.colorScheme.onTertiaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (recipe.entity.isFavorite) {
|
imageVector = if (recipe.entity.isFavorite) {
|
||||||
@@ -137,7 +166,7 @@ private fun RecipeHeader(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun RecipeItemPreview() {
|
private fun RecipeItemPreview() {
|
||||||
val isFavorite = Random.nextBoolean()
|
val isFavorite = Random.nextBoolean()
|
||||||
AppTheme {
|
MealientTheme {
|
||||||
RecipeItem(
|
RecipeItem(
|
||||||
recipe = RecipeListItemState(null, isFavorite, SUMMARY_ENTITY),
|
recipe = RecipeListItemState(null, isFavorite, SUMMARY_ENTITY),
|
||||||
onDeleteClick = {},
|
onDeleteClick = {},
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
package com.atridad.mealient.ui.recipes.list
|
package com.atridad.mealient.ui.recipes.list
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.DrawerState
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.DrawerValue
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.rememberDrawerState
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -22,8 +22,8 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import androidx.paging.LoadState
|
import androidx.paging.LoadState
|
||||||
@@ -34,22 +34,28 @@ import androidx.paging.compose.itemKey
|
|||||||
import com.ramcosta.composedestinations.annotation.Destination
|
import com.ramcosta.composedestinations.annotation.Destination
|
||||||
import com.ramcosta.composedestinations.navigation.navigate
|
import com.ramcosta.composedestinations.navigation.navigate
|
||||||
import com.atridad.mealient.R
|
import com.atridad.mealient.R
|
||||||
import com.atridad.mealient.ui.AppTheme
|
import com.atridad.mealient.ui.theme.Spacing
|
||||||
import com.atridad.mealient.ui.Dimens
|
import com.atridad.mealient.ui.theme.BorderRadius
|
||||||
import com.atridad.mealient.ui.components.BaseScreenState
|
import com.atridad.mealient.ui.components.BaseScreen
|
||||||
import com.atridad.mealient.ui.components.BaseScreenWithNavigation
|
|
||||||
import com.atridad.mealient.ui.components.CenteredProgressIndicator
|
import com.atridad.mealient.ui.components.CenteredProgressIndicator
|
||||||
import com.atridad.mealient.ui.components.LazyPagingColumnPullRefresh
|
import com.atridad.mealient.ui.components.LazyPagingColumnPullRefresh
|
||||||
import com.atridad.mealient.ui.components.OpenDrawerIconButton
|
|
||||||
import com.atridad.mealient.ui.destinations.RecipeScreenDestination
|
import com.atridad.mealient.ui.destinations.RecipeScreenDestination
|
||||||
import com.atridad.mealient.ui.preview.ColorSchemePreview
|
import com.atridad.mealient.ui.destinations.AddRecipeScreenDestination
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import com.atridad.mealient.ui.recipes.list.RecipeListItemState
|
||||||
|
import com.atridad.mealient.ui.recipes.list.RecipeListEvent
|
||||||
|
import com.atridad.mealient.ui.recipes.list.RecipeListState
|
||||||
|
import com.atridad.mealient.ui.recipes.list.ConfirmDeleteDialog
|
||||||
|
import com.atridad.mealient.ui.recipes.list.RecipeItem
|
||||||
|
import com.atridad.mealient.ui.recipes.list.RecipeListSnackbar
|
||||||
|
|
||||||
|
|
||||||
@Destination
|
@Destination
|
||||||
@Composable
|
@Composable
|
||||||
internal fun RecipesList(
|
internal fun RecipesList(
|
||||||
navController: NavController,
|
navController: NavController,
|
||||||
baseScreenState: BaseScreenState,
|
|
||||||
viewModel: RecipesListViewModel = hiltViewModel(),
|
viewModel: RecipesListViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state = viewModel.screenState.collectAsState()
|
val state = viewModel.screenState.collectAsState()
|
||||||
@@ -64,76 +70,140 @@ internal fun RecipesList(
|
|||||||
|
|
||||||
RecipesList(
|
RecipesList(
|
||||||
state = stateValue,
|
state = stateValue,
|
||||||
baseScreenState = baseScreenState,
|
|
||||||
onEvent = viewModel::onEvent,
|
onEvent = viewModel::onEvent,
|
||||||
|
navController = navController,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun RecipesList(
|
private fun RecipesList(
|
||||||
state: RecipeListState,
|
state: RecipeListState,
|
||||||
baseScreenState: BaseScreenState,
|
|
||||||
onEvent: (RecipeListEvent) -> Unit,
|
onEvent: (RecipeListEvent) -> Unit,
|
||||||
|
navController: NavController,
|
||||||
) {
|
) {
|
||||||
val recipes: LazyPagingItems<RecipeListItemState> =
|
val recipes: LazyPagingItems<RecipeListItemState> =
|
||||||
state.pagingDataRecipeState.collectAsLazyPagingItems()
|
state.pagingDataRecipeState.collectAsLazyPagingItems()
|
||||||
val isRefreshing = recipes.loadState.refresh is LoadState.Loading
|
val isRefreshing = recipes.loadState.refresh is LoadState.Loading
|
||||||
var itemToDelete: RecipeListItemState? by remember { mutableStateOf(null) }
|
var itemToDelete: RecipeListItemState? by remember { mutableStateOf(null) }
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
|
||||||
BaseScreenWithNavigation(
|
state.snackbarState?.let { snackbar ->
|
||||||
baseScreenState = baseScreenState,
|
val message = snackbar.message
|
||||||
drawerState = drawerState,
|
LaunchedEffect(message) {
|
||||||
topAppBar = {
|
snackbarHostState.showSnackbar(message)
|
||||||
RecipesTopAppBar(
|
onEvent(RecipeListEvent.SnackbarShown)
|
||||||
searchQuery = state.searchQuery,
|
}
|
||||||
onValueChanged = { onEvent(RecipeListEvent.SearchQueryChanged(it)) },
|
} ?: run {
|
||||||
drawerState = drawerState,
|
snackbarHostState.currentSnackbarData?.dismiss()
|
||||||
)
|
}
|
||||||
},
|
|
||||||
|
itemToDelete?.let { item ->
|
||||||
|
ConfirmDeleteDialog(
|
||||||
|
onDismissRequest = { itemToDelete = null },
|
||||||
|
onConfirm = {
|
||||||
|
onEvent(RecipeListEvent.DeleteConfirmed(item))
|
||||||
|
itemToDelete = null
|
||||||
|
},
|
||||||
|
item = item,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseScreen(
|
||||||
snackbarHostState = snackbarHostState,
|
snackbarHostState = snackbarHostState,
|
||||||
) { modifier ->
|
) { modifier ->
|
||||||
state.snackbarState?.message?.let { message ->
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
LaunchedEffect(message) {
|
Column(
|
||||||
snackbarHostState.showSnackbar(message)
|
modifier = Modifier.fillMaxSize()
|
||||||
onEvent(RecipeListEvent.SnackbarShown)
|
) {
|
||||||
}
|
// Clean, spacious top app bar
|
||||||
} ?: run {
|
androidx.compose.material3.TopAppBar(
|
||||||
snackbarHostState.currentSnackbarData?.dismiss()
|
title = {
|
||||||
}
|
androidx.compose.material3.Text(
|
||||||
|
text = stringResource(R.string.menu_navigation_drawer_recipes_list),
|
||||||
itemToDelete?.let { item ->
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
ConfirmDeleteDialog(
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
onDismissRequest = { itemToDelete = null },
|
)
|
||||||
onConfirm = {
|
},
|
||||||
onEvent(RecipeListEvent.DeleteConfirmed(item))
|
colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors(
|
||||||
itemToDelete = null
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
},
|
titleContentColor = MaterialTheme.colorScheme.onSurface
|
||||||
item = item,
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
when {
|
|
||||||
recipes.itemCount != 0 -> {
|
|
||||||
RecipesListData(
|
|
||||||
modifier = modifier,
|
|
||||||
recipes = recipes,
|
|
||||||
onDeleteClick = { itemToDelete = it },
|
|
||||||
onFavoriteClick = { onEvent(RecipeListEvent.FavoriteClick(it)) },
|
|
||||||
onItemClick = { onEvent(RecipeListEvent.RecipeClick(it)) },
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
isRefreshing -> {
|
// Full-width search bar with proper Material 3 spacing
|
||||||
CenteredProgressIndicator(
|
androidx.compose.material3.OutlinedTextField(
|
||||||
modifier = modifier
|
value = state.searchQuery,
|
||||||
|
onValueChange = { onEvent(RecipeListEvent.SearchQueryChanged(it)) },
|
||||||
|
placeholder = {
|
||||||
|
androidx.compose.material3.Text(
|
||||||
|
text = stringResource(R.string.search_recipes_hint),
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = Spacing.lg, vertical = Spacing.md),
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge,
|
||||||
|
colors = androidx.compose.material3.OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||||
|
unfocusedBorderColor = MaterialTheme.colorScheme.outline,
|
||||||
|
focusedLabelColor = MaterialTheme.colorScheme.primary,
|
||||||
|
unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(BorderRadius.lg),
|
||||||
|
singleLine = true,
|
||||||
|
leadingIcon = {
|
||||||
|
androidx.compose.material3.Icon(
|
||||||
|
imageVector = Icons.Default.Search,
|
||||||
|
contentDescription = "Search",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Recipe list content
|
||||||
|
when {
|
||||||
|
recipes.itemCount != 0 -> {
|
||||||
|
RecipesListData(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
recipes = recipes,
|
||||||
|
onDeleteClick = { itemToDelete = it },
|
||||||
|
onFavoriteClick = { onEvent(RecipeListEvent.FavoriteClick(it)) },
|
||||||
|
onItemClick = { onEvent(RecipeListEvent.RecipeClick(it)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
isRefreshing -> {
|
||||||
|
CenteredProgressIndicator(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
RecipesListError(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
recipes = recipes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
// FAB for adding recipes
|
||||||
RecipesListError(
|
androidx.compose.material3.FloatingActionButton(
|
||||||
modifier = modifier,
|
onClick = {
|
||||||
recipes = recipes,
|
navController.navigate(AddRecipeScreenDestination) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(Spacing.lg),
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
) {
|
||||||
|
androidx.compose.material3.Icon(
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
contentDescription = "Add recipe",
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,8 +242,8 @@ private fun RecipesListData(
|
|||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
lazyPagingItems = recipes,
|
lazyPagingItems = recipes,
|
||||||
verticalArrangement = Arrangement.spacedBy(Dimens.Medium),
|
verticalArrangement = Arrangement.spacedBy(Spacing.md),
|
||||||
contentPadding = PaddingValues(Dimens.Medium),
|
contentPadding = PaddingValues(Spacing.md),
|
||||||
) {
|
) {
|
||||||
items(
|
items(
|
||||||
count = recipes.itemCount,
|
count = recipes.itemCount,
|
||||||
@@ -195,45 +265,3 @@ private fun RecipesListData(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun RecipesTopAppBar(
|
|
||||||
searchQuery: String,
|
|
||||||
onValueChanged: (String) -> Unit,
|
|
||||||
drawerState: DrawerState,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(
|
|
||||||
horizontal = Dimens.Medium,
|
|
||||||
vertical = Dimens.Small,
|
|
||||||
)
|
|
||||||
.clip(RoundedCornerShape(Dimens.Medium))
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
|
||||||
.padding(end = Dimens.Medium),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
OpenDrawerIconButton(
|
|
||||||
drawerState = drawerState,
|
|
||||||
)
|
|
||||||
|
|
||||||
SearchTextField(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f),
|
|
||||||
searchQuery = searchQuery,
|
|
||||||
onValueChanged = onValueChanged,
|
|
||||||
placeholder = R.string.search_recipes_hint,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ColorSchemePreview
|
|
||||||
@Composable
|
|
||||||
private fun RecipesTopAppBarPreview() {
|
|
||||||
AppTheme {
|
|
||||||
RecipesTopAppBar(
|
|
||||||
searchQuery = "",
|
|
||||||
onValueChanged = {},
|
|
||||||
drawerState = rememberDrawerState(initialValue = DrawerValue.Closed),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,13 +5,14 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import androidx.paging.PagingData
|
import androidx.paging.PagingData
|
||||||
import androidx.paging.cachedIn
|
import androidx.paging.cachedIn
|
||||||
import androidx.paging.map
|
import androidx.paging.map
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import com.atridad.mealient.architecture.valueUpdatesOnly
|
import com.atridad.mealient.architecture.valueUpdatesOnly
|
||||||
import com.atridad.mealient.data.auth.AuthRepo
|
import com.atridad.mealient.data.auth.AuthRepo
|
||||||
import com.atridad.mealient.data.recipes.RecipeRepo
|
import com.atridad.mealient.data.recipes.RecipeRepo
|
||||||
import com.atridad.mealient.data.recipes.impl.RecipeImageUrlProvider
|
import com.atridad.mealient.data.recipes.impl.RecipeImageUrlProvider
|
||||||
import com.atridad.mealient.database.recipe.entity.RecipeSummaryEntity
|
import com.atridad.mealient.database.recipe.entity.RecipeSummaryEntity
|
||||||
import com.atridad.mealient.logging.Logger
|
import com.atridad.mealient.logging.Logger
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -23,44 +24,48 @@ import kotlinx.coroutines.flow.onEach
|
|||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
internal class RecipesListViewModel @Inject constructor(
|
internal class RecipesListViewModel
|
||||||
private val recipeRepo: RecipeRepo,
|
@Inject
|
||||||
private val logger: Logger,
|
constructor(
|
||||||
private val recipeImageUrlProvider: RecipeImageUrlProvider,
|
private val recipeRepo: RecipeRepo,
|
||||||
authRepo: AuthRepo,
|
private val logger: Logger,
|
||||||
|
private val recipeImageUrlProvider: RecipeImageUrlProvider,
|
||||||
|
authRepo: AuthRepo,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val pagingData: Flow<PagingData<RecipeSummaryEntity>> =
|
private val pagingData: Flow<PagingData<RecipeSummaryEntity>> =
|
||||||
recipeRepo.createPager().flow.cachedIn(viewModelScope)
|
recipeRepo.createPager().flow.cachedIn(viewModelScope)
|
||||||
|
|
||||||
private val showFavoriteIcon: StateFlow<Boolean> =
|
private val showFavoriteIcon: StateFlow<Boolean> =
|
||||||
authRepo.isAuthorizedFlow.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
authRepo.isAuthorizedFlow.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||||
|
|
||||||
private val pagingDataRecipeState: Flow<PagingData<RecipeListItemState>> =
|
private val pagingDataRecipeState: Flow<PagingData<RecipeListItemState>> =
|
||||||
pagingData.combine(showFavoriteIcon) { data, showFavorite ->
|
pagingData.combine(showFavoriteIcon) { data, showFavorite ->
|
||||||
data.map { item ->
|
data.map { item ->
|
||||||
val imageUrl = recipeImageUrlProvider.generateImageUrl(item.imageId)
|
val imageUrl = recipeImageUrlProvider.generateImageUrl(item.imageId)
|
||||||
RecipeListItemState(
|
RecipeListItemState(
|
||||||
imageUrl = imageUrl,
|
imageUrl = imageUrl,
|
||||||
showFavoriteIcon = showFavorite,
|
showFavoriteIcon = showFavorite,
|
||||||
entity = item,
|
entity = item,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private val _screenState = MutableStateFlow(
|
private val _screenState =
|
||||||
RecipeListState(pagingDataRecipeState = pagingDataRecipeState)
|
MutableStateFlow(RecipeListState(pagingDataRecipeState = pagingDataRecipeState))
|
||||||
)
|
val screenState: StateFlow<RecipeListState>
|
||||||
val screenState: StateFlow<RecipeListState> get() = _screenState.asStateFlow()
|
get() = _screenState.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
authRepo.isAuthorizedFlow.valueUpdatesOnly().onEach { hasAuthorized ->
|
authRepo.isAuthorizedFlow
|
||||||
logger.v { "Authorization state changed to $hasAuthorized" }
|
.valueUpdatesOnly()
|
||||||
if (hasAuthorized) recipeRepo.refreshRecipes()
|
.onEach { hasAuthorized ->
|
||||||
}.launchIn(viewModelScope)
|
logger.v { "Authorization state changed to $hasAuthorized" }
|
||||||
|
if (hasAuthorized) recipeRepo.refreshRecipes()
|
||||||
|
}
|
||||||
|
.launchIn(viewModelScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onRecipeClicked(entity: RecipeSummaryEntity) {
|
private fun onRecipeClicked(entity: RecipeSummaryEntity) {
|
||||||
@@ -75,23 +80,23 @@ internal class RecipesListViewModel @Inject constructor(
|
|||||||
private fun onFavoriteIconClick(recipeSummaryEntity: RecipeSummaryEntity) {
|
private fun onFavoriteIconClick(recipeSummaryEntity: RecipeSummaryEntity) {
|
||||||
logger.v { "onFavoriteIconClick() called with: recipeSummaryEntity = $recipeSummaryEntity" }
|
logger.v { "onFavoriteIconClick() called with: recipeSummaryEntity = $recipeSummaryEntity" }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result = recipeRepo.updateIsRecipeFavorite(
|
val result =
|
||||||
recipeSlug = recipeSummaryEntity.slug,
|
recipeRepo.updateIsRecipeFavorite(
|
||||||
isFavorite = recipeSummaryEntity.isFavorite.not(),
|
recipeSlug = recipeSummaryEntity.slug,
|
||||||
)
|
isFavorite = recipeSummaryEntity.isFavorite.not(),
|
||||||
val snackbar = result.fold(
|
)
|
||||||
onSuccess = { isFavorite ->
|
val snackbar =
|
||||||
val name = recipeSummaryEntity.name
|
result.fold(
|
||||||
if (isFavorite) {
|
onSuccess = { _ ->
|
||||||
RecipeListSnackbar.FavoriteAdded(name)
|
val name = recipeSummaryEntity.name
|
||||||
} else {
|
if (recipeSummaryEntity.isFavorite) {
|
||||||
RecipeListSnackbar.FavoriteRemoved(name)
|
RecipeListSnackbar.FavoriteRemoved(name)
|
||||||
}
|
} else {
|
||||||
},
|
RecipeListSnackbar.FavoriteAdded(name)
|
||||||
onFailure = {
|
}
|
||||||
RecipeListSnackbar.FavoriteUpdateFailed
|
},
|
||||||
}
|
onFailure = { RecipeListSnackbar.FavoriteUpdateFailed }
|
||||||
)
|
)
|
||||||
_screenState.update { it.copy(snackbarState = snackbar) }
|
_screenState.update { it.copy(snackbarState = snackbar) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,10 +106,11 @@ internal class RecipesListViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result = recipeRepo.deleteRecipe(recipeSummaryEntity)
|
val result = recipeRepo.deleteRecipe(recipeSummaryEntity)
|
||||||
logger.d { "onDeleteConfirm: delete result is $result" }
|
logger.d { "onDeleteConfirm: delete result is $result" }
|
||||||
val snackbar = result.fold(
|
val snackbar =
|
||||||
onSuccess = { null },
|
result.fold(
|
||||||
onFailure = { RecipeListSnackbar.DeleteFailed },
|
onSuccess = { null },
|
||||||
)
|
onFailure = { RecipeListSnackbar.DeleteFailed },
|
||||||
|
)
|
||||||
_screenState.update { it.copy(snackbarState = snackbar) }
|
_screenState.update { it.copy(snackbarState = snackbar) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,13 +141,15 @@ internal class RecipesListViewModel @Inject constructor(
|
|||||||
_screenState.update { it.copy(searchQuery = event.query) }
|
_screenState.update { it.copy(searchQuery = event.query) }
|
||||||
recipeRepo.updateNameQuery(event.query)
|
recipeRepo.updateNameQuery(event.query)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal data class RecipeListState(
|
internal data class RecipeListState(
|
||||||
val pagingDataRecipeState: Flow<PagingData<RecipeListItemState>>,
|
val pagingDataRecipeState: Flow<PagingData<RecipeListItemState>>,
|
||||||
val snackbarState: RecipeListSnackbar? = null,
|
val snackbarState: RecipeListSnackbar? = null,
|
||||||
val recipeIdToOpen: String? = null,
|
val recipeIdToOpen: String? = null,
|
||||||
val searchQuery: String = "",
|
val searchQuery: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
internal sealed interface RecipeListEvent {
|
internal sealed interface RecipeListEvent {
|
||||||
@@ -157,4 +165,6 @@ internal sealed interface RecipeListEvent {
|
|||||||
data object SnackbarShown : RecipeListEvent
|
data object SnackbarShown : RecipeListEvent
|
||||||
|
|
||||||
data class SearchQueryChanged(val query: String) : RecipeListEvent
|
data class SearchQueryChanged(val query: String) : RecipeListEvent
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -16,8 +16,9 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.semantics.testTag
|
import androidx.compose.ui.semantics.testTag
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import com.atridad.mealient.R
|
import com.atridad.mealient.R
|
||||||
import com.atridad.mealient.ui.AppTheme
|
import com.atridad.mealient.ui.theme.MealientTheme
|
||||||
import com.atridad.mealient.ui.preview.ColorSchemePreview
|
import com.atridad.mealient.ui.preview.ColorSchemePreview
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -27,7 +28,7 @@ internal fun SearchTextField(
|
|||||||
@StringRes placeholder: Int,
|
@StringRes placeholder: Int,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
TextField(
|
androidx.compose.material3.OutlinedTextField(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.semantics { testTag = "search-recipes-field" },
|
.semantics { testTag = "search-recipes-field" },
|
||||||
value = searchQuery,
|
value = searchQuery,
|
||||||
@@ -35,12 +36,15 @@ internal fun SearchTextField(
|
|||||||
placeholder = {
|
placeholder = {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = placeholder),
|
text = stringResource(id = placeholder),
|
||||||
|
style = androidx.compose.material3.MaterialTheme.typography.bodyMedium,
|
||||||
|
color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
leadingIcon = {
|
leadingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Search,
|
imageVector = Icons.Default.Search,
|
||||||
contentDescription = null,
|
contentDescription = stringResource(R.string.search_recipes_hint),
|
||||||
|
tint = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
@@ -50,19 +54,24 @@ internal fun SearchTextField(
|
|||||||
onSearch = { defaultKeyboardAction(ImeAction.Done) }
|
onSearch = { defaultKeyboardAction(ImeAction.Done) }
|
||||||
),
|
),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
colors = TextFieldDefaults.colors(
|
textStyle = androidx.compose.material3.MaterialTheme.typography.bodyLarge,
|
||||||
focusedIndicatorColor = Color.Transparent,
|
colors = androidx.compose.material3.OutlinedTextFieldDefaults.colors(
|
||||||
unfocusedIndicatorColor = Color.Transparent,
|
focusedBorderColor = androidx.compose.material3.MaterialTheme.colorScheme.primary,
|
||||||
disabledIndicatorColor = Color.Transparent,
|
unfocusedBorderColor = androidx.compose.material3.MaterialTheme.colorScheme.outline,
|
||||||
errorIndicatorColor = Color.Transparent
|
focusedLabelColor = androidx.compose.material3.MaterialTheme.colorScheme.primary,
|
||||||
)
|
unfocusedLabelColor = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
focusedTextColor = androidx.compose.material3.MaterialTheme.colorScheme.onSurface,
|
||||||
|
unfocusedTextColor = androidx.compose.material3.MaterialTheme.colorScheme.onSurface,
|
||||||
|
cursorColor = androidx.compose.material3.MaterialTheme.colorScheme.primary
|
||||||
|
),
|
||||||
|
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ColorSchemePreview
|
@ColorSchemePreview
|
||||||
@Composable
|
@Composable
|
||||||
private fun SearchTextFieldPreview() {
|
private fun SearchTextFieldPreview() {
|
||||||
AppTheme {
|
MealientTheme {
|
||||||
SearchTextField(
|
SearchTextField(
|
||||||
searchQuery = "",
|
searchQuery = "",
|
||||||
onValueChanged = {},
|
onValueChanged = {},
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package com.atridad.mealient.ui.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.DarkMode
|
||||||
|
import androidx.compose.material.icons.filled.LightMode
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||||
|
import androidx.compose.material.icons.filled.Smartphone
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import com.ramcosta.composedestinations.annotation.Destination
|
||||||
|
import com.atridad.mealient.ui.theme.Spacing
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
|
||||||
|
@Destination
|
||||||
|
@Composable
|
||||||
|
internal fun SettingsScreen(
|
||||||
|
viewModel: SettingsViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.screenState.collectAsState()
|
||||||
|
|
||||||
|
SettingsScreen(
|
||||||
|
state = state,
|
||||||
|
onEvent = viewModel::onEvent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsScreen(
|
||||||
|
state: SettingsScreenState,
|
||||||
|
onEvent: (SettingsScreenEvent) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(Spacing.lg),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(Spacing.lg)
|
||||||
|
) {
|
||||||
|
// Header
|
||||||
|
Text(
|
||||||
|
text = "Settings",
|
||||||
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
|
||||||
|
// Theme Selection Card
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(
|
||||||
|
defaultElevation = 2.dp
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(Spacing.lg)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Theme",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.size(Spacing.md))
|
||||||
|
|
||||||
|
// Theme options
|
||||||
|
ThemeOption(
|
||||||
|
title = "Device",
|
||||||
|
subtitle = "Follow system theme",
|
||||||
|
icon = Icons.Default.Smartphone,
|
||||||
|
isSelected = state.themeMode == ThemeMode.DEVICE,
|
||||||
|
onClick = { onEvent(SettingsScreenEvent.ThemeModeChanged(ThemeMode.DEVICE)) }
|
||||||
|
)
|
||||||
|
|
||||||
|
androidx.compose.material3.HorizontalDivider(modifier = Modifier.padding(vertical = Spacing.sm))
|
||||||
|
|
||||||
|
ThemeOption(
|
||||||
|
title = "Light",
|
||||||
|
subtitle = "Always use light theme",
|
||||||
|
icon = Icons.Default.LightMode,
|
||||||
|
isSelected = state.themeMode == ThemeMode.LIGHT,
|
||||||
|
onClick = { onEvent(SettingsScreenEvent.ThemeModeChanged(ThemeMode.LIGHT)) }
|
||||||
|
)
|
||||||
|
|
||||||
|
androidx.compose.material3.HorizontalDivider(modifier = Modifier.padding(vertical = Spacing.sm))
|
||||||
|
|
||||||
|
ThemeOption(
|
||||||
|
title = "Dark",
|
||||||
|
subtitle = "Always use dark theme",
|
||||||
|
icon = Icons.Default.DarkMode,
|
||||||
|
isSelected = state.themeMode == ThemeMode.DARK,
|
||||||
|
onClick = { onEvent(SettingsScreenEvent.ThemeModeChanged(ThemeMode.DARK)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout Card
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(
|
||||||
|
defaultElevation = 2.dp
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = "Logout",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
text = "Sign out of your account",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.Logout,
|
||||||
|
contentDescription = "Logout",
|
||||||
|
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.clickable { onEvent(SettingsScreenEvent.Logout) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ThemeOption(
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
icon: ImageVector,
|
||||||
|
isSelected: Boolean,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onClick() }
|
||||||
|
.padding(vertical = Spacing.sm),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.size(Spacing.md))
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
RadioButton(
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = onClick
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.atridad.mealient.ui.settings
|
||||||
|
|
||||||
|
internal sealed interface SettingsScreenEvent {
|
||||||
|
data class ThemeModeChanged(val themeMode: ThemeMode) : SettingsScreenEvent
|
||||||
|
data object Logout : SettingsScreenEvent
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.atridad.mealient.ui.settings
|
||||||
|
|
||||||
|
data class SettingsScreenState(
|
||||||
|
val themeMode: ThemeMode = ThemeMode.DEVICE,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class ThemeMode {
|
||||||
|
DEVICE,
|
||||||
|
LIGHT,
|
||||||
|
DARK,
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.atridad.mealient.ui.settings
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.atridad.mealient.data.storage.PreferencesStorage
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
internal class SettingsViewModel @Inject constructor(
|
||||||
|
private val prefs: PreferencesStorage,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _screenState = MutableStateFlow(SettingsScreenState())
|
||||||
|
val screenState: StateFlow<SettingsScreenState> = _screenState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val stored = prefs.getValue(prefs.themeModeKey)
|
||||||
|
val mode = stored?.let { runCatching { ThemeMode.valueOf(it) }.getOrNull() }
|
||||||
|
if (mode != null) {
|
||||||
|
_screenState.value = _screenState.value.copy(themeMode = mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEvent(event: SettingsScreenEvent) {
|
||||||
|
when (event) {
|
||||||
|
is SettingsScreenEvent.ThemeModeChanged -> {
|
||||||
|
_screenState.value = _screenState.value.copy(
|
||||||
|
themeMode = event.themeMode
|
||||||
|
)
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.storeValues(Pair(prefs.themeModeKey, event.themeMode.name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is SettingsScreenEvent.Logout -> {
|
||||||
|
_screenState.value = _screenState.value.copy(
|
||||||
|
isLoading = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<plurals name="fragment_disclaimer_button_okay_timer">
|
|
||||||
<item quantity="one">Okay (%d Sekunde)</item>
|
|
||||||
<item quantity="other">Okay (%d Sekunden)</item>
|
|
||||||
</plurals>
|
|
||||||
</resources>
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<string name="fragment_authentication_input_hint_email">E-Mail oder Nutzername</string>
|
|
||||||
<string name="fragment_authentication_input_hint_password">Passwort</string>
|
|
||||||
<string name="fragment_authentication_input_hint_url">Server-URL</string>
|
|
||||||
<string name="fragment_authentication_button_login">Anmeldung</string>
|
|
||||||
<string name="content_description_view_holder_recipe_image">Bild der gekochten Mahlzeit</string>
|
|
||||||
<string name="menu_navigation_drawer_logout">Abmeldung</string>
|
|
||||||
<string name="view_holder_recipe_text_placeholder">Laden…</string>
|
|
||||||
<string name="fragment_recipe_info_ingredients_header">Inhaltsstoffe</string>
|
|
||||||
<string name="fragment_recipe_info_instructions_header">Anweisungen</string>
|
|
||||||
<string name="fragment_disclaimer_main_text">Dieses Projekt wird unabhängig vom Mealie-Kernprojekt entwickelt. Es ist NICHT mit den Mealie-Kernentwicklern verbunden. Alle Probleme müssen an das Mealient-Repository und NICHT an das Mealie-Repository gemeldet werden.</string>
|
|
||||||
<string name="fragment_baseurl_url_input_empty">URL darf nicht leer sein</string>
|
|
||||||
<string name="fragment_base_url_no_connection">Kann keine Verbindung herstellen, Adresse prüfen.</string>
|
|
||||||
<string name="fragment_base_url_unexpected_response">Unerwartete Antwort. Ist es Mealie?</string>
|
|
||||||
<string name="fragment_base_url_malformed_url">URL-Format prüfen: %s</string>
|
|
||||||
<string name="fragment_base_url_save">Weiter</string>
|
|
||||||
<string name="fragment_base_url_invalid_certificate_title">Die Identität des Servers konnte nicht überprüft werden</string>
|
|
||||||
<string name="fragment_base_url_invalid_certificate_message">Vertrauen Sie diesem Zertifikat?\n\nInformationen zum Zertifikat:\nAussteller: %1$s\nBetreff: %2$s\nGültig von: %3$s\nGültig bis: %4$s</string>
|
|
||||||
<string name="fragment_base_url_invalid_certificate_accept">Vertrauen</string>
|
|
||||||
<string name="fragment_base_url_invalid_certificate_deny">Nein</string>
|
|
||||||
<string name="menu_navigation_drawer_login">Anmeldung</string>
|
|
||||||
<string name="fragment_disclaimer_button_okay">Okay</string>
|
|
||||||
<string name="view_holder_recipe_instructions_step">Schritt: %d</string>
|
|
||||||
<string name="fragment_authentication_email_input_empty">E-Mail kann nicht leer sein</string>
|
|
||||||
<string name="fragment_authentication_password_input_empty">Das Passwort darf nicht leer sein</string>
|
|
||||||
<string name="fragment_authentication_credentials_incorrect">E-Mail oder Passwort sind falsch.</string>
|
|
||||||
<string name="fragment_authentication_unknown_error">Es ist ein Fehler aufgetreten, bitte versuchen Sie es erneut.</string>
|
|
||||||
<string name="fragment_add_recipe_recipe_name">Name des Rezepts</string>
|
|
||||||
<string name="fragment_add_recipe_recipe_description">Beschreibung</string>
|
|
||||||
<string name="menu_navigation_drawer_add_recipe">Rezept hinzufügen</string>
|
|
||||||
<string name="menu_navigation_drawer_recipes_list">Rezepte</string>
|
|
||||||
<string name="fragment_add_recipe_recipe_yield">Ausbeute des Rezepts</string>
|
|
||||||
<string name="fragment_add_recipe_save_button">Rezept speichern</string>
|
|
||||||
<string name="fragment_add_recipe_new_instruction">Neuer Schritt</string>
|
|
||||||
<string name="fragment_add_recipe_new_ingredient">Neue Zutat</string>
|
|
||||||
<string name="fragment_add_recipe_public_recipe">Öffentliches Rezept</string>
|
|
||||||
<string name="fragment_add_recipe_disable_comments">Kommentare deaktivieren</string>
|
|
||||||
<string name="fragment_add_recipe_ingredient_hint">Zutat</string>
|
|
||||||
<string name="fragment_add_recipe_instruction_hint">Beschreibung der Schritte</string>
|
|
||||||
<string name="fragment_add_recipe_name_error">Rezeptname darf nicht leer sein</string>
|
|
||||||
<string name="fragment_add_recipe_save_error">Etwas ist schief gelaufen</string>
|
|
||||||
<string name="fragment_add_recipe_save_success">Rezept erfolgreich gespeichert</string>
|
|
||||||
<string name="fragment_add_recipe_clear_button">Klar</string>
|
|
||||||
<string name="fragment_base_url_url_input_helper_text">Beispiel: demo.mealie.io</string>
|
|
||||||
<string name="fragment_authentication_email_input_helper_text">Beispiel: changeme@example.com</string>
|
|
||||||
<string name="fragment_authentication_password_input_helper_text">Beispiel: MyPassword</string>
|
|
||||||
<string name="fragment_recipes_last_page_loaded_toast">Zuletzt geladene Seite</string>
|
|
||||||
<string name="fragment_recipes_load_failure_toast" comment="EXAMPLE: Load error: unauthorized.">Ladefehler: %1$s.</string>
|
|
||||||
<string name="fragment_recipes_load_failure_toast_no_reason">Laden fehlgeschlagen.</string>
|
|
||||||
<string name="fragment_recipes_load_failure_toast_unauthorized">unbefugt</string>
|
|
||||||
<string name="fragment_recipes_load_failure_toast_unexpected_response">unerwartete Antwort</string>
|
|
||||||
<string name="fragment_recipes_load_failure_toast_no_connection">keine Verbindung</string>
|
|
||||||
<string name="fragment_recipes_favorite_update_failed">Favoritenstatusaktualisierung fehlgeschlagen</string>
|
|
||||||
<string name="fragment_recipes_delete_recipe_failed">Rezeptentfernung fehlgeschlagen</string>
|
|
||||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_title">Rezept löschen</string>
|
|
||||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_message">Sind Sie sicher, dass Sie %1$slöschen möchten? Dies kann nicht rückgängig gemacht werden.</string>
|
|
||||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_positive_btn">Bestätigen Sie</string>
|
|
||||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_negative_btn">Abbrechen</string>
|
|
||||||
<string name="menu_navigation_drawer_change_url">URL ändern</string>
|
|
||||||
<string name="search_recipes_hint">Rezepte suchen</string>
|
|
||||||
<string name="view_toolbar_navigation_icon_content_description">Navigationsschublade öffnen</string>
|
|
||||||
<string name="fragment_recipes_list_no_recipes">Keine Rezepte</string>
|
|
||||||
<string name="activity_share_recipe_success_toast">Rezept erfolgreich gespeichert.</string>
|
|
||||||
<string name="activity_share_recipe_failure_toast">Etwas ist schief gelaufen.</string>
|
|
||||||
<string name="content_description_activity_share_recipe_progress">Indikator für den Fortschritt</string>
|
|
||||||
<string name="view_holder_recipe_favorite_content_description">Artikel ist Favorit</string>
|
|
||||||
<string name="view_holder_recipe_non_favorite_content_description">Artikel ist nicht beliebt</string>
|
|
||||||
<string name="view_holder_recipe_delete_content_description">Rezept löschen</string>
|
|
||||||
<string name="fragment_recipes_favorite_added">%1$s zu den Favoriten hinzugefügt</string>
|
|
||||||
<string name="fragment_recipes_favorite_removed">%1$s aus den Favoriten entfernt</string>
|
|
||||||
<string name="menu_navigation_drawer_shopping_lists">Einkaufslisten</string>
|
|
||||||
<string name="menu_navigation_drawer_email_logs">E-Mail Protokolle</string>
|
|
||||||
<string name="activity_main_email_logs_subject">Mealient Protokolle</string>
|
|
||||||
<string name="activity_main_email_logs_confirmation_message">Die Protokolle enthalten sensible Daten, wie zum Beispiel API-Token, Einkaufslisten und Rezepte. API-Token können per Web-Client widerrufen werden. Die Datei kann angesehen und bearbeitet werden, wenn Sie sie stattdessen an sich selbst senden.</string>
|
|
||||||
<string name="activity_main_email_logs_confirmation_title">Sende sensible Daten</string>
|
|
||||||
<string name="activity_main_email_logs_confirmation_positive">Wählen Sie eine Sendemethode</string>
|
|
||||||
<string name="activity_main_email_logs_confirmation_negative">Abbrechen</string>
|
|
||||||
<string name="activity_main_logout_confirmation_title">Abmelden läuft</string>
|
|
||||||
<string name="activity_main_logout_confirmation_message">Sind Sie sicher, dass Sie sich abmelden möchten?</string>
|
|
||||||
<string name="activity_main_logout_confirmation_positive">Abmelden</string>
|
|
||||||
<string name="activity_main_logout_confirmation_negative">Abbrechen</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<plurals name="fragment_disclaimer_button_okay_timer">
|
|
||||||
<item quantity="one">Bien, (%d segundo)</item>
|
|
||||||
<item quantity="other">Bien, (%d segundos)</item>
|
|
||||||
</plurals>
|
|
||||||
</resources>
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<string name="fragment_authentication_input_hint_email">Email o nombre de usuario</string>
|
|
||||||
<string name="fragment_authentication_input_hint_password">Contraseña</string>
|
|
||||||
<string name="fragment_authentication_input_hint_url">URL del servidor</string>
|
|
||||||
<string name="fragment_authentication_button_login">Iniciar sesión</string>
|
|
||||||
<string name="content_description_view_holder_recipe_image">Foto de la comida cocinada</string>
|
|
||||||
<string name="menu_navigation_drawer_logout">Cerrar sesión</string>
|
|
||||||
<string name="view_holder_recipe_text_placeholder">Cargando…</string>
|
|
||||||
<string name="fragment_recipe_info_ingredients_header">Ingredientes</string>
|
|
||||||
<string name="fragment_recipe_info_instructions_header">Instrucciones</string>
|
|
||||||
<string name="fragment_disclaimer_main_text">Este proyecto se desarrolla independientemente del proyecto Mealie. NO está asociado con los desarrolladores de Mealie. Cualquier problema debe ser reportado al repositorio de Mealient, NO al repositorio de Mealie.</string>
|
|
||||||
<string name="fragment_baseurl_url_input_empty">La URL no puede estar vacía</string>
|
|
||||||
<string name="fragment_base_url_no_connection">No se puede conectar, verifique la dirección.</string>
|
|
||||||
<string name="fragment_base_url_unexpected_response">Respuesta inesperada. ¿Es Mealie?</string>
|
|
||||||
<string name="fragment_base_url_malformed_url">Comprobar el formato de URL: %s</string>
|
|
||||||
<string name="fragment_base_url_save">Continuar</string>
|
|
||||||
<string name="fragment_base_url_invalid_certificate_title">No se ha podido verificar la identidad del servidor</string>
|
|
||||||
<string name="fragment_base_url_invalid_certificate_message">¿Confía en este certificado?\n\nInformación del certificado:\nEmisor: %1$s\nAsunto: %2$s\nVálido Desde: %3$s\nVálido hasta: %4$s</string>
|
|
||||||
<string name="fragment_base_url_invalid_certificate_accept">Confíe en</string>
|
|
||||||
<string name="fragment_base_url_invalid_certificate_deny">No</string>
|
|
||||||
<string name="menu_navigation_drawer_login">Iniciar sesión</string>
|
|
||||||
<string name="fragment_disclaimer_button_okay">Aceptar</string>
|
|
||||||
<string name="view_holder_recipe_instructions_step">Paso: %d</string>
|
|
||||||
<string name="fragment_authentication_email_input_empty">El correo electrónico no puede estar vacío</string>
|
|
||||||
<string name="fragment_authentication_password_input_empty">La contraseña no puede estar vacía</string>
|
|
||||||
<string name="fragment_authentication_credentials_incorrect">Correo electrónico o contraseña incorrectos.</string>
|
|
||||||
<string name="fragment_authentication_unknown_error">Algo salió mal, por favor vuelve a intentarlo.</string>
|
|
||||||
<string name="fragment_add_recipe_recipe_name">Nombre de la receta</string>
|
|
||||||
<string name="fragment_add_recipe_recipe_description">Descripción</string>
|
|
||||||
<string name="menu_navigation_drawer_add_recipe">Agregar receta</string>
|
|
||||||
<string name="menu_navigation_drawer_recipes_list">Recetas</string>
|
|
||||||
<string name="fragment_add_recipe_recipe_yield">Porciones</string>
|
|
||||||
<string name="fragment_add_recipe_save_button">Guardar receta</string>
|
|
||||||
<string name="fragment_add_recipe_new_instruction">Nuevo paso</string>
|
|
||||||
<string name="fragment_add_recipe_new_ingredient">Nuevo ingrediente</string>
|
|
||||||
<string name="fragment_add_recipe_public_recipe">Receta pública</string>
|
|
||||||
<string name="fragment_add_recipe_disable_comments">Desactivar comentarios</string>
|
|
||||||
<string name="fragment_add_recipe_ingredient_hint">Ingrediente</string>
|
|
||||||
<string name="fragment_add_recipe_instruction_hint">Descripción del paso</string>
|
|
||||||
<string name="fragment_add_recipe_name_error">El nombre de la receta no puede estar vacío</string>
|
|
||||||
<string name="fragment_add_recipe_save_error">Algo salió mal</string>
|
|
||||||
<string name="fragment_add_recipe_save_success">Receta guardada con éxito</string>
|
|
||||||
<string name="fragment_add_recipe_clear_button">Limpiar</string>
|
|
||||||
<string name="fragment_base_url_url_input_helper_text">Ejemplo: demo.mealie.io</string>
|
|
||||||
<string name="fragment_authentication_email_input_helper_text">Ejemplo: changeme@example.com</string>
|
|
||||||
<string name="fragment_authentication_password_input_helper_text">Ejemplo: MyPassword</string>
|
|
||||||
<string name="fragment_recipes_last_page_loaded_toast">Última página cargada</string>
|
|
||||||
<string name="fragment_recipes_load_failure_toast" comment="EXAMPLE: Load error: unauthorized.">Error al cargar: %1$s.</string>
|
|
||||||
<string name="fragment_recipes_load_failure_toast_no_reason">La carga falló.</string>
|
|
||||||
<string name="fragment_recipes_load_failure_toast_unauthorized">no autorizado</string>
|
|
||||||
<string name="fragment_recipes_load_failure_toast_unexpected_response">respuesta inesperada</string>
|
|
||||||
<string name="fragment_recipes_load_failure_toast_no_connection">sin conexión</string>
|
|
||||||
<string name="fragment_recipes_favorite_update_failed">Error al actualizar el estado de favorito</string>
|
|
||||||
<string name="fragment_recipes_delete_recipe_failed">Error al eliminar la receta</string>
|
|
||||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_title">Eliminar receta</string>
|
|
||||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_message">¿Está seguro que desea eliminar %1$s? Esto no se puede deshacer.</string>
|
|
||||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_positive_btn">Confirmar</string>
|
|
||||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_negative_btn">Cancelar</string>
|
|
||||||
<string name="menu_navigation_drawer_change_url">Cambiar URL</string>
|
|
||||||
<string name="search_recipes_hint">Buscar recetas</string>
|
|
||||||
<string name="view_toolbar_navigation_icon_content_description">Abrir cajón de navegación</string>
|
|
||||||
<string name="fragment_recipes_list_no_recipes">Sin recetas</string>
|
|
||||||
<string name="activity_share_recipe_success_toast">Receta guardada exitosamente.</string>
|
|
||||||
<string name="activity_share_recipe_failure_toast">Algo salió mal.</string>
|
|
||||||
<string name="content_description_activity_share_recipe_progress">Indicador de progreso</string>
|
|
||||||
<string name="view_holder_recipe_favorite_content_description">El artículo es favorito</string>
|
|
||||||
<string name="view_holder_recipe_non_favorite_content_description">El artículo no es favorito</string>
|
|
||||||
<string name="view_holder_recipe_delete_content_description">Eliminar receta</string>
|
|
||||||
<string name="fragment_recipes_favorite_added">Añadido %1$s a favoritos</string>
|
|
||||||
<string name="fragment_recipes_favorite_removed">Eliminado %1$s de favoritos</string>
|
|
||||||
<string name="menu_navigation_drawer_shopping_lists">Listas de la compra</string>
|
|
||||||
<string name="menu_navigation_drawer_email_logs">Registros de correo electrónico</string>
|
|
||||||
<string name="activity_main_email_logs_subject">Registros mealientes</string>
|
|
||||||
<string name="activity_main_email_logs_confirmation_message">Los registros contienen datos sensibles como tokens de API, listas de la compra y recetas. Los tokens de API se pueden revocar mediante el cliente web. El archivo se puede ver y editar si te lo envías a ti mismo.</string>
|
|
||||||
<string name="activity_main_email_logs_confirmation_title">Envío de datos sensibles</string>
|
|
||||||
<string name="activity_main_email_logs_confirmation_positive">Elija cómo enviar</string>
|
|
||||||
<string name="activity_main_email_logs_confirmation_negative">Cancelar</string>
|
|
||||||
<string name="activity_main_logout_confirmation_title">Cerrar sesión</string>
|
|
||||||
<string name="activity_main_logout_confirmation_message">¿Seguro que quieres desconectarte?</string>
|
|
||||||
<string name="activity_main_logout_confirmation_positive">Cerrar sesión</string>
|
|
||||||
<string name="activity_main_logout_confirmation_negative">Cancelar</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<plurals name="fragment_disclaimer_button_okay_timer">
|
|
||||||
<item quantity="one">Ok (%d seconde)</item>
|
|
||||||
<item quantity="other">Ok (%d secondes)</item>
|
|
||||||
</plurals>
|
|
||||||
</resources>
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<string name="fragment_authentication_input_hint_email">Email ou nom d\'utilisateur</string>
|
|
||||||
<string name="fragment_authentication_input_hint_password">Mot de passe</string>
|
|
||||||
<string name="fragment_authentication_input_hint_url">URL du serveur</string>
|
|
||||||
<string name="fragment_authentication_button_login">Connexion</string>
|
|
||||||
<string name="content_description_view_holder_recipe_image">Photo du repas cuisiné</string>
|
|
||||||
<string name="menu_navigation_drawer_logout">Déconnexion</string>
|
|
||||||
<string name="view_holder_recipe_text_placeholder">Chargement de…</string>
|
|
||||||
<string name="fragment_recipe_info_ingredients_header">Ingrédients</string>
|
|
||||||
<string name="fragment_recipe_info_instructions_header">Instructions</string>
|
|
||||||
<string name="fragment_disclaimer_main_text">Ce projet est développé indépendamment du projet principal Mealie. Il n\'est PAS associé aux développeurs de Mealie. Tout problème doit être signalé au dépôt Mealient, et NON au dépôt Mealie.</string>
|
|
||||||
<string name="fragment_baseurl_url_input_empty">L\'URL ne peut pas être vide</string>
|
|
||||||
<string name="fragment_base_url_no_connection">Impossible de se connecter, vérifier l\'adresse.</string>
|
|
||||||
<string name="fragment_base_url_unexpected_response">Réponse inattendue. Est-ce Mealie ?</string>
|
|
||||||
<string name="fragment_base_url_malformed_url">Vérifier le format de l\'URL : %s</string>
|
|
||||||
<string name="fragment_base_url_save">Procéder</string>
|
|
||||||
<string name="fragment_base_url_invalid_certificate_title">L\'identité du serveur n\'a pas pu être vérifiée</string>
|
|
||||||
<string name="fragment_base_url_invalid_certificate_message">Faites-vous confiance à ce certificat ?\n\nInformations sur le certificat :\nÉmetteur : %1$s\nSujet : %2$s\nValable du : %3$s\nValable jusqu\'au : %4$s</string>
|
|
||||||
<string name="fragment_base_url_invalid_certificate_accept">Confiance</string>
|
|
||||||
<string name="fragment_base_url_invalid_certificate_deny">Non</string>
|
|
||||||
<string name="menu_navigation_drawer_login">Connexion</string>
|
|
||||||
<string name="fragment_disclaimer_button_okay">D\'accord</string>
|
|
||||||
<string name="view_holder_recipe_instructions_step">Étape : %d</string>
|
|
||||||
<string name="fragment_authentication_email_input_empty">L\'e-mail ne peut pas être vide</string>
|
|
||||||
<string name="fragment_authentication_password_input_empty">Le mot de passe ne peut pas être vide</string>
|
|
||||||
<string name="fragment_authentication_credentials_incorrect">L\'e-mail ou le mot de passe est incorrect.</string>
|
|
||||||
<string name="fragment_authentication_unknown_error">Un problème s\'est produit, veuillez réessayer.</string>
|
|
||||||
<string name="fragment_add_recipe_recipe_name">Nom de la recette</string>
|
|
||||||
<string name="fragment_add_recipe_recipe_description">Description</string>
|
|
||||||
<string name="menu_navigation_drawer_add_recipe">Ajouter une recette</string>
|
|
||||||
<string name="menu_navigation_drawer_recipes_list">Recettes</string>
|
|
||||||
<string name="fragment_add_recipe_recipe_yield">Rendement de la recette</string>
|
|
||||||
<string name="fragment_add_recipe_save_button">Enregistrer la recette</string>
|
|
||||||
<string name="fragment_add_recipe_new_instruction">Nouvelle étape</string>
|
|
||||||
<string name="fragment_add_recipe_new_ingredient">Nouvel ingrédient</string>
|
|
||||||
<string name="fragment_add_recipe_public_recipe">Recette publique</string>
|
|
||||||
<string name="fragment_add_recipe_disable_comments">Désactiver les commentaires</string>
|
|
||||||
<string name="fragment_add_recipe_ingredient_hint">Ingrédient</string>
|
|
||||||
<string name="fragment_add_recipe_instruction_hint">Description des étapes</string>
|
|
||||||
<string name="fragment_add_recipe_name_error">Le nom de la recette ne peut pas être vide</string>
|
|
||||||
<string name="fragment_add_recipe_save_error">Quelque chose n\'a pas fonctionné</string>
|
|
||||||
<string name="fragment_add_recipe_save_success">Sauvegarde réussie de la recette</string>
|
|
||||||
<string name="fragment_add_recipe_clear_button">Clair</string>
|
|
||||||
<string name="fragment_base_url_url_input_helper_text">Exemple : demo.mealie.io</string>
|
|
||||||
<string name="fragment_authentication_email_input_helper_text">Exemple : changeme@example.com</string>
|
|
||||||
<string name="fragment_authentication_password_input_helper_text">Exemple : MyPassword</string>
|
|
||||||
<string name="fragment_recipes_last_page_loaded_toast">Dernière page chargée</string>
|
|
||||||
<string name="fragment_recipes_load_failure_toast" comment="EXAMPLE: Load error: unauthorized.">Erreur de chargement : %1$s.</string>
|
|
||||||
<string name="fragment_recipes_load_failure_toast_no_reason">Le chargement a échoué.</string>
|
|
||||||
<string name="fragment_recipes_load_failure_toast_unauthorized">non autorisé</string>
|
|
||||||
<string name="fragment_recipes_load_failure_toast_unexpected_response">réponse inattendue</string>
|
|
||||||
<string name="fragment_recipes_load_failure_toast_no_connection">pas de connexion</string>
|
|
||||||
<string name="fragment_recipes_favorite_update_failed">La mise à jour du statut de favori a échoué</string>
|
|
||||||
<string name="fragment_recipes_delete_recipe_failed">Échec de la suppression de la recette</string>
|
|
||||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_title">Supprimer la recette</string>
|
|
||||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_message">Êtes-vous sûr de vouloir supprimer %1$s? Cette opération ne peut être annulée.</string>
|
|
||||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_positive_btn">Confirmer</string>
|
|
||||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_negative_btn">Annuler</string>
|
|
||||||
<string name="menu_navigation_drawer_change_url">Modifier l\'URL</string>
|
|
||||||
<string name="search_recipes_hint">Rechercher des recettes</string>
|
|
||||||
<string name="view_toolbar_navigation_icon_content_description">Ouvrir le tiroir de navigation</string>
|
|
||||||
<string name="fragment_recipes_list_no_recipes">Pas de recettes</string>
|
|
||||||
<string name="activity_share_recipe_success_toast">La recette a été enregistrée avec succès.</string>
|
|
||||||
<string name="activity_share_recipe_failure_toast">Quelque chose n\'a pas fonctionné.</string>
|
|
||||||
<string name="content_description_activity_share_recipe_progress">Indicateur de progrès</string>
|
|
||||||
<string name="view_holder_recipe_favorite_content_description">L\'article est le préféré</string>
|
|
||||||
<string name="view_holder_recipe_non_favorite_content_description">L\'article n\'est pas favori</string>
|
|
||||||
<string name="view_holder_recipe_delete_content_description">Supprimer la recette</string>
|
|
||||||
<string name="fragment_recipes_favorite_added">Ajout de %1$s aux favoris</string>
|
|
||||||
<string name="fragment_recipes_favorite_removed">Suppression de %1$s des favoris</string>
|
|
||||||
<string name="menu_navigation_drawer_shopping_lists">Listes de courses</string>
|
|
||||||
<string name="menu_navigation_drawer_email_logs">Journaux des courriels</string>
|
|
||||||
<string name="activity_main_email_logs_subject">Journaux de la maltraitance</string>
|
|
||||||
<string name="activity_main_email_logs_confirmation_message">Les journaux contiennent des données sensibles telles que les jetons API, les listes d\'achats et les recettes. Les jetons d\'API peuvent être révoqués à l\'aide du client web. Le fichier peut être consulté et modifié si vous vous l\'envoyez à vous-même.</string>
|
|
||||||
<string name="activity_main_email_logs_confirmation_title">Envoi de données sensibles</string>
|
|
||||||
<string name="activity_main_email_logs_confirmation_positive">Choisir le mode d\'envoi</string>
|
|
||||||
<string name="activity_main_email_logs_confirmation_negative">Annuler</string>
|
|
||||||
<string name="activity_main_logout_confirmation_title">Déconnexion</string>
|
|
||||||
<string name="activity_main_logout_confirmation_message">Êtes-vous sûr de vouloir vous déconnecter ?</string>
|
|
||||||
<string name="activity_main_logout_confirmation_positive">Déconnexion</string>
|
|
||||||
<string name="activity_main_logout_confirmation_negative">Annuler</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,9 +1,19 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
|
|
||||||
<style name="Theme.App.Starting" parent="Theme.SplashScreen">
|
<style name="Theme.App.Starting" parent="Theme.SplashScreen">
|
||||||
<item name="postSplashScreenTheme">@style/AppTheme</item>
|
<item name="postSplashScreenTheme">@style/AppTheme</item>
|
||||||
<item name="windowSplashScreenBackground">@android:color/black</item>
|
<item name="windowSplashScreenBackground">@android:color/black</item>
|
||||||
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash_screen</item>
|
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash_screen</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<style name="AppTheme" parent="Theme.Material3.DynamicColors.DayNight">
|
||||||
|
<item name="windowActionBar">false</item>
|
||||||
|
<item name="windowNoTitle">true</item>
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:windowLightStatusBar">false</item>
|
||||||
|
<item name="android:windowLightNavigationBar">false</item>
|
||||||
|
<item name="android:enforceNavigationBarContrast">false</item>
|
||||||
|
<item name="android:enforceStatusBarContrast">false</item>
|
||||||
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<plurals name="fragment_disclaimer_button_okay_timer">
|
|
||||||
<item quantity="one">Oké (%d seconde)</item>
|
|
||||||
<item quantity="other">Oké (%d seconden)</item>
|
|
||||||
</plurals>
|
|
||||||
</resources>
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<string name="fragment_authentication_input_hint_email">E-mail of gebruikersnaam</string>
|
|
||||||
<string name="fragment_authentication_input_hint_password">Wachtwoord</string>
|
|
||||||
<string name="fragment_authentication_input_hint_url">Server URL</string>
|
|
||||||
<string name="fragment_authentication_button_login">Inloggen</string>
|
|
||||||
<string name="content_description_view_holder_recipe_image">Foto van de bereide maaltijd</string>
|
|
||||||
<string name="menu_navigation_drawer_logout">Afmelden</string>
|
|
||||||
<string name="view_holder_recipe_text_placeholder">Laden…</string>
|
|
||||||
<string name="fragment_recipe_info_ingredients_header">Ingrediënten</string>
|
|
||||||
<string name="fragment_recipe_info_instructions_header">Instructies</string>
|
|
||||||
<string name="fragment_disclaimer_main_text">Dit project wordt onafhankelijk van het Mealie-kernproject ontwikkeld. Het is NIET verbonden met de kernontwikkelaars van Mealie. Eventuele problemen moeten worden gerapporteerd aan de Mealient repository, NIET aan de Mealie repository.</string>
|
|
||||||
<string name="fragment_baseurl_url_input_empty">URL kan niet leeg zijn</string>
|
|
||||||
<string name="fragment_base_url_no_connection">Kan geen verbinding maken, controleer adres.</string>
|
|
||||||
<string name="fragment_base_url_unexpected_response">Onverwachte reactie. Is het Mealie?</string>
|
|
||||||
<string name="fragment_base_url_malformed_url">Controleer URL-indeling: %s</string>
|
|
||||||
<string name="fragment_base_url_save">Ga verder</string>
|
|
||||||
<string name="fragment_base_url_invalid_certificate_title">De identiteit van de server kon niet worden geverifieerd</string>
|
|
||||||
<string name="fragment_base_url_invalid_certificate_message">Vertrouwt u dit certificaat?\n\nCertificaatinformatie:\nUitgevende instelling: %1$s\nOnderwerp: %2$s\nGeldig vanaf: %3$s\nGeldig tot: %4$s</string>
|
|
||||||
<string name="fragment_base_url_invalid_certificate_accept">Vertrouwen</string>
|
|
||||||
<string name="fragment_base_url_invalid_certificate_deny">Geen</string>
|
|
||||||
<string name="menu_navigation_drawer_login">Inloggen</string>
|
|
||||||
<string name="fragment_disclaimer_button_okay">Oké</string>
|
|
||||||
<string name="view_holder_recipe_instructions_step">Stap: %d</string>
|
|
||||||
<string name="fragment_authentication_email_input_empty">E-mail kan niet leeg zijn</string>
|
|
||||||
<string name="fragment_authentication_password_input_empty">Wachtwoord kan niet leeg zijn</string>
|
|
||||||
<string name="fragment_authentication_credentials_incorrect">E-mail of wachtwoord is onjuist.</string>
|
|
||||||
<string name="fragment_authentication_unknown_error">Er is iets misgegaan, probeer het opnieuw.</string>
|
|
||||||
<string name="fragment_add_recipe_recipe_name">Naam recept</string>
|
|
||||||
<string name="fragment_add_recipe_recipe_description">Beschrijving</string>
|
|
||||||
<string name="menu_navigation_drawer_add_recipe">Recept toevoegen</string>
|
|
||||||
<string name="menu_navigation_drawer_recipes_list">Recepten</string>
|
|
||||||
<string name="fragment_add_recipe_recipe_yield">Recept opbrengst</string>
|
|
||||||
<string name="fragment_add_recipe_save_button">Recept opslaan</string>
|
|
||||||
<string name="fragment_add_recipe_new_instruction">Nieuwe stap</string>
|
|
||||||
<string name="fragment_add_recipe_new_ingredient">Nieuw ingrediënt</string>
|
|
||||||
<string name="fragment_add_recipe_public_recipe">Publiek recept</string>
|
|
||||||
<string name="fragment_add_recipe_disable_comments">Opmerkingen uitschakelen</string>
|
|
||||||
<string name="fragment_add_recipe_ingredient_hint">Ingrediënt</string>
|
|
||||||
<string name="fragment_add_recipe_instruction_hint">Stapbeschrijving</string>
|
|
||||||
<string name="fragment_add_recipe_name_error">Receptnaam kan niet leeg zijn</string>
|
|
||||||
<string name="fragment_add_recipe_save_error">Er ging iets mis</string>
|
|
||||||
<string name="fragment_add_recipe_save_success">Recept succesvol opgeslagen</string>
|
|
||||||
<string name="fragment_add_recipe_clear_button">Duidelijk</string>
|
|
||||||
<string name="fragment_base_url_url_input_helper_text">Voorbeeld: demo.mealie.io</string>
|
|
||||||
<string name="fragment_authentication_email_input_helper_text">Voorbeeld: changeme@example.com</string>
|
|
||||||
<string name="fragment_authentication_password_input_helper_text">Voorbeeld: MyPassword</string>
|
|
||||||
<string name="fragment_recipes_last_page_loaded_toast">Laatste pagina geladen</string>
|
|
||||||
<string name="fragment_recipes_load_failure_toast" comment="EXAMPLE: Load error: unauthorized.">Fout bij laden: %1$s.</string>
|
|
||||||
<string name="fragment_recipes_load_failure_toast_no_reason">Laden mislukt.</string>
|
|
||||||
<string name="fragment_recipes_load_failure_toast_unauthorized">onbevoegd</string>
|
|
||||||
<string name="fragment_recipes_load_failure_toast_unexpected_response">onverwachte reactie</string>
|
|
||||||
<string name="fragment_recipes_load_failure_toast_no_connection">geen verbinding</string>
|
|
||||||
<string name="fragment_recipes_favorite_update_failed">Favoriete statusupdate mislukt</string>
|
|
||||||
<string name="fragment_recipes_delete_recipe_failed">Verwijderen van recept mislukt</string>
|
|
||||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_title">Recept verwijderen</string>
|
|
||||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_message">Weet je zeker dat je %1$swilt verwijderen? Dit kan niet ongedaan worden gemaakt.</string>
|
|
||||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_positive_btn">Bevestig</string>
|
|
||||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_negative_btn">Annuleren</string>
|
|
||||||
<string name="menu_navigation_drawer_change_url">URL wijzigen</string>
|
|
||||||
<string name="search_recipes_hint">Recepten zoeken</string>
|
|
||||||
<string name="view_toolbar_navigation_icon_content_description">Open de navigatielade</string>
|
|
||||||
<string name="fragment_recipes_list_no_recipes">Geen recepten</string>
|
|
||||||
<string name="activity_share_recipe_success_toast">Recept succesvol opgeslagen.</string>
|
|
||||||
<string name="activity_share_recipe_failure_toast">Er ging iets mis.</string>
|
|
||||||
<string name="content_description_activity_share_recipe_progress">Voortgangsindicator</string>
|
|
||||||
<string name="view_holder_recipe_favorite_content_description">Item is favoriet</string>
|
|
||||||
<string name="view_holder_recipe_non_favorite_content_description">Item is niet favoriet</string>
|
|
||||||
<string name="view_holder_recipe_delete_content_description">Recept verwijderen</string>
|
|
||||||
<string name="fragment_recipes_favorite_added">%1$s toegevoegd aan favorieten</string>
|
|
||||||
<string name="fragment_recipes_favorite_removed">Verwijderde %1$s uit favorieten</string>
|
|
||||||
<string name="menu_navigation_drawer_shopping_lists">Boodschappenlijstjes</string>
|
|
||||||
<string name="menu_navigation_drawer_email_logs">Logboeken e-mail</string>
|
|
||||||
<string name="activity_main_email_logs_subject">Mealient logs</string>
|
|
||||||
<string name="activity_main_email_logs_confirmation_message">De logs bevatten gevoelige gegevens zoals API-tokens, boodschappenlijsten en recepten. API-tokens kunnen worden ingetrokken met de webclient. Het bestand kan worden bekeken en bewerkt als je het in plaats daarvan naar jezelf stuurt.</string>
|
|
||||||
<string name="activity_main_email_logs_confirmation_title">Gevoelige gegevens verzenden</string>
|
|
||||||
<string name="activity_main_email_logs_confirmation_positive">Kies hoe te verzenden</string>
|
|
||||||
<string name="activity_main_email_logs_confirmation_negative">Annuleren</string>
|
|
||||||
<string name="activity_main_logout_confirmation_title">Afmelden</string>
|
|
||||||
<string name="activity_main_logout_confirmation_message">Weet je zeker dat je jezelf wilt afmelden?</string>
|
|
||||||
<string name="activity_main_logout_confirmation_positive">Afmelden</string>
|
|
||||||
<string name="activity_main_logout_confirmation_negative">Annuleren</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<plurals name="fragment_disclaimer_button_okay_timer">
|
|
||||||
<item quantity="one">Ok (%d segundo)</item>
|
|
||||||
<item quantity="other">Ok (%d segundos)</item>
|
|
||||||
</plurals>
|
|
||||||
</resources>
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<string name="fragment_authentication_input_hint_email">E-mail ou nome de utilizador</string>
|
|
||||||
<string name="fragment_authentication_input_hint_password">Palavra-passe</string>
|
|
||||||
<string name="fragment_authentication_input_hint_url">URL do servidor</string>
|
|
||||||
<string name="fragment_authentication_button_login">Iniciar sessão</string>
|
|
||||||
<string name="content_description_view_holder_recipe_image">Fotografia da refeição cozinhada</string>
|
|
||||||
<string name="menu_navigation_drawer_logout">Terminar sessão</string>
|
|
||||||
<string name="view_holder_recipe_text_placeholder">Carregando…</string>
|
|
||||||
<string name="fragment_recipe_info_ingredients_header">Ingredientes</string>
|
|
||||||
<string name="fragment_recipe_info_instructions_header">Instruções</string>
|
|
||||||
<string name="fragment_disclaimer_main_text">Este projeto é desenvolvido independentemente do projeto principal do Mealie. Ele NÃO está associado aos desenvolvedores do Mealie. Quaisquer problemas devem ser reportados ao repositório Mealient, NÃO ao repositório Mealie.</string>
|
|
||||||
<string name="fragment_baseurl_url_input_empty">O URL não pode estar vazio</string>
|
|
||||||
<string name="fragment_base_url_no_connection">Não é possível estabelecer ligação, verificar endereço.</string>
|
|
||||||
<string name="fragment_base_url_unexpected_response">Resposta inesperada. É a Mealie?</string>
|
|
||||||
<string name="fragment_base_url_malformed_url">Verificar o formato do URL: %s</string>
|
|
||||||
<string name="fragment_base_url_save">Prosseguir</string>
|
|
||||||
<string name="fragment_base_url_invalid_certificate_title">A identidade do servidor não pôde ser verificada</string>
|
|
||||||
<string name="fragment_base_url_invalid_certificate_message">Confia neste certificado?\n\nInformações sobre o certificado:\nEmissor: %1$s\nAssunto: %2$s\nVálido de: %3$s\nVálido até: %4$s</string>
|
|
||||||
<string name="fragment_base_url_invalid_certificate_accept">Confiança</string>
|
|
||||||
<string name="fragment_base_url_invalid_certificate_deny">Não</string>
|
|
||||||
<string name="menu_navigation_drawer_login">Iniciar sessão</string>
|
|
||||||
<string name="fragment_disclaimer_button_okay">Está bem</string>
|
|
||||||
<string name="view_holder_recipe_instructions_step">Passo: %d</string>
|
|
||||||
<string name="fragment_authentication_email_input_empty">O correio eletrónico não pode estar vazio</string>
|
|
||||||
<string name="fragment_authentication_password_input_empty">A palavra-passe não pode estar vazia</string>
|
|
||||||
<string name="fragment_authentication_credentials_incorrect">O e-mail ou a palavra-passe estão incorrectos.</string>
|
|
||||||
<string name="fragment_authentication_unknown_error">Algo correu mal, por favor tente novamente.</string>
|
|
||||||
<string name="fragment_add_recipe_recipe_name">Nome da receita</string>
|
|
||||||
<string name="fragment_add_recipe_recipe_description">Descrição</string>
|
|
||||||
<string name="menu_navigation_drawer_add_recipe">Adicionar receita</string>
|
|
||||||
<string name="menu_navigation_drawer_recipes_list">Receitas</string>
|
|
||||||
<string name="fragment_add_recipe_recipe_yield">Rendimento da receita</string>
|
|
||||||
<string name="fragment_add_recipe_save_button">Guardar receita</string>
|
|
||||||
<string name="fragment_add_recipe_new_instruction">Nova etapa</string>
|
|
||||||
<string name="fragment_add_recipe_new_ingredient">Novo ingrediente</string>
|
|
||||||
<string name="fragment_add_recipe_public_recipe">Receita pública</string>
|
|
||||||
<string name="fragment_add_recipe_disable_comments">Desativar comentários</string>
|
|
||||||
<string name="fragment_add_recipe_ingredient_hint">Ingrediente</string>
|
|
||||||
<string name="fragment_add_recipe_instruction_hint">Descrição das etapas</string>
|
|
||||||
<string name="fragment_add_recipe_name_error">O nome da receita não pode estar vazio</string>
|
|
||||||
<string name="fragment_add_recipe_save_error">Algo correu mal</string>
|
|
||||||
<string name="fragment_add_recipe_save_success">Receita guardada com sucesso</string>
|
|
||||||
<string name="fragment_add_recipe_clear_button">Limpo</string>
|
|
||||||
<string name="fragment_base_url_url_input_helper_text">Exemplo: demo.mealie.io</string>
|
|
||||||
<string name="fragment_authentication_email_input_helper_text">Exemplo: changeme@example.com</string>
|
|
||||||
<string name="fragment_authentication_password_input_helper_text">Exemplo: MyPassword</string>
|
|
||||||
<string name="fragment_recipes_last_page_loaded_toast">Última página carregada</string>
|
|
||||||
<string name="fragment_recipes_load_failure_toast" comment="EXAMPLE: Load error: unauthorized.">Erro de carregamento: %1$s.</string>
|
|
||||||
<string name="fragment_recipes_load_failure_toast_no_reason">O carregamento falhou.</string>
|
|
||||||
<string name="fragment_recipes_load_failure_toast_unauthorized">não autorizado</string>
|
|
||||||
<string name="fragment_recipes_load_failure_toast_unexpected_response">resposta inesperada</string>
|
|
||||||
<string name="fragment_recipes_load_failure_toast_no_connection">sem ligação</string>
|
|
||||||
<string name="fragment_recipes_favorite_update_failed">Falha na atualização do estado dos favoritos</string>
|
|
||||||
<string name="fragment_recipes_delete_recipe_failed">Falha na remoção da receita</string>
|
|
||||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_title">Eliminar receita</string>
|
|
||||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_message">Tem a certeza de que pretende apagar %1$s? Isto não pode ser anulado.</string>
|
|
||||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_positive_btn">Confirmar</string>
|
|
||||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_negative_btn">Cancelar</string>
|
|
||||||
<string name="menu_navigation_drawer_change_url">Alterar URL</string>
|
|
||||||
<string name="search_recipes_hint">Pesquisar receitas</string>
|
|
||||||
<string name="view_toolbar_navigation_icon_content_description">Abrir a gaveta de navegação</string>
|
|
||||||
<string name="fragment_recipes_list_no_recipes">Sem receitas</string>
|
|
||||||
<string name="activity_share_recipe_success_toast">Receita guardada com sucesso.</string>
|
|
||||||
<string name="activity_share_recipe_failure_toast">Alguma coisa correu mal.</string>
|
|
||||||
<string name="content_description_activity_share_recipe_progress">Indicador de progresso</string>
|
|
||||||
<string name="view_holder_recipe_favorite_content_description">O item é favorito</string>
|
|
||||||
<string name="view_holder_recipe_non_favorite_content_description">O item não é favorito</string>
|
|
||||||
<string name="view_holder_recipe_delete_content_description">Eliminar receita</string>
|
|
||||||
<string name="fragment_recipes_favorite_added">Adicionado %1$s aos favoritos</string>
|
|
||||||
<string name="fragment_recipes_favorite_removed">Removido %1$s dos favoritos</string>
|
|
||||||
<string name="menu_navigation_drawer_shopping_lists">Listas de compras</string>
|
|
||||||
<string name="menu_navigation_drawer_email_logs">Registos de correio eletrónico</string>
|
|
||||||
<string name="activity_main_email_logs_subject">Registos de refeições</string>
|
|
||||||
<string name="activity_main_email_logs_confirmation_message">Os registos contêm dados sensíveis, como o token da API, listas de compras e receitas. Os tokens da API podem ser revogados através do cliente Web. O ficheiro pode ser visualizado e editado se, em vez disso, o enviar para si próprio.</string>
|
|
||||||
<string name="activity_main_email_logs_confirmation_title">Envio de dados sensíveis</string>
|
|
||||||
<string name="activity_main_email_logs_confirmation_positive">Escolher como enviar</string>
|
|
||||||
<string name="activity_main_email_logs_confirmation_negative">Cancelar</string>
|
|
||||||
<string name="activity_main_logout_confirmation_title">Terminar a sessão</string>
|
|
||||||
<string name="activity_main_logout_confirmation_message">Tem a certeza de que pretende terminar a sessão?</string>
|
|
||||||
<string name="activity_main_logout_confirmation_positive">Terminar sessão</string>
|
|
||||||
<string name="activity_main_logout_confirmation_negative">Cancelar</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<plurals name="fragment_disclaimer_button_okay_timer">
|
|
||||||
<item quantity="one">Хорошо (%d секунда)</item>
|
|
||||||
<item quantity="few">Хорошо (%d секунды)</item>
|
|
||||||
<item quantity="many">Хорошо (%d секунд)</item>
|
|
||||||
<item quantity="other">Хорошо (%d секунд)</item>
|
|
||||||
</plurals>
|
|
||||||
</resources>
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<string name="fragment_authentication_input_hint_email">Email или username</string>
|
|
||||||
<string name="fragment_authentication_input_hint_password">Пароль</string>
|
|
||||||
<string name="fragment_authentication_input_hint_url">URL сервера</string>
|
|
||||||
<string name="fragment_authentication_button_login">Войти</string>
|
|
||||||
<string name="content_description_view_holder_recipe_image">Изображение готового блюда</string>
|
|
||||||
<string name="menu_navigation_drawer_logout">Выйти</string>
|
|
||||||
<string name="view_holder_recipe_text_placeholder">Загрузка</string>
|
|
||||||
<string name="fragment_recipe_info_ingredients_header">Ингредиенты</string>
|
|
||||||
<string name="fragment_recipe_info_instructions_header">Инструкции</string>
|
|
||||||
<string name="fragment_disclaimer_main_text">Этот проект разрабатывается независимо от основного проекта Meale. Он не связан с разработчиками Mealie. О любых проблемах следует писать в репозиторий Mealient, НЕ в репозиторий Mealie.</string>
|
|
||||||
<string name="fragment_baseurl_url_input_empty">URL не может быть пустым</string>
|
|
||||||
<string name="fragment_base_url_no_connection">Ошибка подключения, проверьте адрес.</string>
|
|
||||||
<string name="fragment_base_url_unexpected_response">Неожиданный ответ. Это Mealie?</string>
|
|
||||||
<string name="fragment_base_url_malformed_url">Проверьте формат URL: %s</string>
|
|
||||||
<string name="fragment_base_url_save">Продолжить</string>
|
|
||||||
<string name="fragment_base_url_invalid_certificate_title">Не удалось проверить подлинность сервера</string>
|
|
||||||
<string name="fragment_base_url_invalid_certificate_message">Доверяете ли вы этому сертификату?\n\nИнформация о сертификате:\nIssuer: %1$s\nSubject: %2$s\nДействителен с: %3$s\nДействителен до: %4$s</string>
|
|
||||||
<string name="fragment_base_url_invalid_certificate_accept">Доверять</string>
|
|
||||||
<string name="fragment_base_url_invalid_certificate_deny">Нет</string>
|
|
||||||
<string name="menu_navigation_drawer_login">Войти</string>
|
|
||||||
<string name="fragment_disclaimer_button_okay">Хорошо</string>
|
|
||||||
<string name="view_holder_recipe_instructions_step">Шаг: %d</string>
|
|
||||||
<string name="fragment_authentication_email_input_empty">E-mail не может быть пустым</string>
|
|
||||||
<string name="fragment_authentication_password_input_empty">Пароль не может быть пустым</string>
|
|
||||||
<string name="fragment_authentication_credentials_incorrect">E-mail или пароль не подходит.</string>
|
|
||||||
<string name="fragment_authentication_unknown_error">Что-то пошло не так, попробуйте еще раз.</string>
|
|
||||||
<string name="fragment_add_recipe_recipe_name">Название рецепта</string>
|
|
||||||
<string name="fragment_add_recipe_recipe_description">Описание</string>
|
|
||||||
<string name="menu_navigation_drawer_add_recipe">Добавить рецепт</string>
|
|
||||||
<string name="menu_navigation_drawer_recipes_list">Рецепты</string>
|
|
||||||
<string name="fragment_add_recipe_recipe_yield">Количество порций</string>
|
|
||||||
<string name="fragment_add_recipe_save_button">Сохранить рецепт</string>
|
|
||||||
<string name="fragment_add_recipe_new_instruction">Добавить шаг</string>
|
|
||||||
<string name="fragment_add_recipe_new_ingredient">Добавить ингредиент</string>
|
|
||||||
<string name="fragment_add_recipe_public_recipe">Публичный рецепт</string>
|
|
||||||
<string name="fragment_add_recipe_disable_comments">Отключить комментарии</string>
|
|
||||||
<string name="fragment_add_recipe_ingredient_hint">Ингредиент</string>
|
|
||||||
<string name="fragment_add_recipe_instruction_hint">Описание шага</string>
|
|
||||||
<string name="fragment_add_recipe_name_error">Имя рецепта не может быть пустым</string>
|
|
||||||
<string name="fragment_add_recipe_save_error">Что-то пошло не так</string>
|
|
||||||
<string name="fragment_add_recipe_save_success">Рецепт сохранен успешно</string>
|
|
||||||
<string name="fragment_add_recipe_clear_button">Очистить</string>
|
|
||||||
<string name="fragment_base_url_url_input_helper_text">Пример: demo.mealie.io</string>
|
|
||||||
<string name="fragment_authentication_email_input_helper_text">Пример: changeme@example.com</string>
|
|
||||||
<string name="fragment_authentication_password_input_helper_text">Пример: MyPassword</string>
|
|
||||||
<string name="fragment_recipes_last_page_loaded_toast">Последняя страница</string>
|
|
||||||
<string name="fragment_recipes_load_failure_toast" comment="EXAMPLE: Load error: unauthorized.">Ошибка загрузки: %1$s.</string>
|
|
||||||
<string name="fragment_recipes_load_failure_toast_no_reason">Ошибка загрузки.</string>
|
|
||||||
<string name="fragment_recipes_load_failure_toast_unauthorized">неавторизован</string>
|
|
||||||
<string name="fragment_recipes_load_failure_toast_unexpected_response">неожиданный ответ</string>
|
|
||||||
<string name="fragment_recipes_load_failure_toast_no_connection">нет соединения</string>
|
|
||||||
<string name="fragment_recipes_favorite_update_failed">Не удалось обновить статус избранного</string>
|
|
||||||
<string name="fragment_recipes_delete_recipe_failed">Не удалось удалить рецепт</string>
|
|
||||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_title">Удалить рецепт</string>
|
|
||||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_message">Вы уверены, что хотите удалить %1$s? Удаление необратимо.</string>
|
|
||||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_positive_btn">Подтвердить</string>
|
|
||||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_negative_btn">Отмена</string>
|
|
||||||
<string name="menu_navigation_drawer_change_url">Сменить URL</string>
|
|
||||||
<string name="search_recipes_hint">Найти рецепты</string>
|
|
||||||
<string name="view_toolbar_navigation_icon_content_description">Открыть меню навигации</string>
|
|
||||||
<string name="fragment_recipes_list_no_recipes">Нет рецептов</string>
|
|
||||||
<string name="activity_share_recipe_success_toast">Рецепт успешно сохранен.</string>
|
|
||||||
<string name="activity_share_recipe_failure_toast">Что-то пошло не так.</string>
|
|
||||||
<string name="content_description_activity_share_recipe_progress">Индикатор прогресса</string>
|
|
||||||
<string name="view_holder_recipe_favorite_content_description">Добавлен в избранное</string>
|
|
||||||
<string name="view_holder_recipe_non_favorite_content_description">Не добавлен в избранное</string>
|
|
||||||
<string name="view_holder_recipe_delete_content_description">Удалить рецепт</string>
|
|
||||||
<string name="fragment_recipes_favorite_added">%1$s добавлено в избранное</string>
|
|
||||||
<string name="fragment_recipes_favorite_removed">%1$s удалено из избранного</string>
|
|
||||||
<string name="menu_navigation_drawer_shopping_lists">Списки покупок</string>
|
|
||||||
<string name="menu_navigation_drawer_email_logs">Журналы электронной почты</string>
|
|
||||||
<string name="activity_main_email_logs_subject">Бревна для меалиентов</string>
|
|
||||||
<string name="activity_main_email_logs_confirmation_message">В журналах содержатся конфиденциальные данные, такие как API-токен, списки покупок и рецепты. API-токены могут быть отозваны с помощью веб-клиента. Файл можно просматривать и редактировать, если отправить его самому себе.</string>
|
|
||||||
<string name="activity_main_email_logs_confirmation_title">Отправка конфиденциальных данных</string>
|
|
||||||
<string name="activity_main_email_logs_confirmation_positive">Выберите способ отправки</string>
|
|
||||||
<string name="activity_main_email_logs_confirmation_negative">Отмена</string>
|
|
||||||
<string name="activity_main_logout_confirmation_title">Выход из системы</string>
|
|
||||||
<string name="activity_main_logout_confirmation_message">Вы уверены, что хотите выйти из системы?</string>
|
|
||||||
<string name="activity_main_logout_confirmation_positive">Выйти из системы</string>
|
|
||||||
<string name="activity_main_logout_confirmation_negative">Отмена</string>
|
|
||||||
</resources>
|
|
||||||
@@ -58,6 +58,7 @@
|
|||||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_message">Are you sure you want to delete %1$s? This cannot be undone.</string>
|
<string name="fragment_recipes_delete_recipe_confirm_dialog_message">Are you sure you want to delete %1$s? This cannot be undone.</string>
|
||||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_positive_btn">Confirm</string>
|
<string name="fragment_recipes_delete_recipe_confirm_dialog_positive_btn">Confirm</string>
|
||||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_negative_btn">Cancel</string>
|
<string name="fragment_recipes_delete_recipe_confirm_dialog_negative_btn">Cancel</string>
|
||||||
|
<string name="menu_navigation_drawer_profile">Profile</string>
|
||||||
<string name="menu_navigation_drawer_change_url">Change URL</string>
|
<string name="menu_navigation_drawer_change_url">Change URL</string>
|
||||||
<string name="search_recipes_hint">Search recipes</string>
|
<string name="search_recipes_hint">Search recipes</string>
|
||||||
<string name="view_toolbar_navigation_icon_content_description">Open navigation drawer</string>
|
<string name="view_toolbar_navigation_icon_content_description">Open navigation drawer</string>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<resources>
|
<resources>
|
||||||
|
|
||||||
<style name="Theme.App.Starting" parent="Theme.SplashScreen">
|
<style name="Theme.App.Starting" parent="Theme.SplashScreen">
|
||||||
<item name="postSplashScreenTheme">@style/AppTheme</item>
|
<item name="postSplashScreenTheme">@style/AppTheme</item>
|
||||||
<item name="windowSplashScreenBackground">@android:color/white</item>
|
<item name="windowSplashScreenBackground">@android:color/white</item>
|
||||||
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash_screen</item>
|
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash_screen</item>
|
||||||
<item name="android:windowLightStatusBar">true</item>
|
<item name="android:windowLightStatusBar">true</item>
|
||||||
|
<item name="android:windowSplashScreenBrandingImage">@null</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style name="AppTheme" parent="Theme.Material3.DynamicColors.DayNight">
|
<style name="AppTheme" parent="Theme.Material3.DynamicColors.DayNight">
|
||||||
@@ -12,5 +12,7 @@
|
|||||||
<item name="windowNoTitle">true</item>
|
<item name="windowNoTitle">true</item>
|
||||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:enforceNavigationBarContrast">false</item>
|
||||||
|
<item name="android:enforceStatusBarContrast">false</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import com.android.build.gradle.internal.dsl.BaseAppModuleExtension
|
||||||
|
import com.atridad.mealient.configureAndroidCompose
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.kotlin.dsl.configure
|
||||||
|
|
||||||
|
class AndroidApplicationComposeConventionPlugin : Plugin<Project> {
|
||||||
|
|
||||||
|
override fun apply(target: Project) {
|
||||||
|
with(target) {
|
||||||
|
pluginManager.apply("com.android.application")
|
||||||
|
pluginManager.apply("org.jetbrains.kotlin.plugin.compose")
|
||||||
|
|
||||||
|
extensions.configure<BaseAppModuleExtension> {
|
||||||
|
configureAndroidCompose(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import com.android.build.gradle.internal.dsl.BaseAppModuleExtension
|
||||||
|
import com.atridad.mealient.Versions
|
||||||
|
import com.atridad.mealient.configureKotlinAndroid
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.kotlin.dsl.configure
|
||||||
|
|
||||||
|
class AndroidApplicationConventionPlugin : Plugin<Project> {
|
||||||
|
|
||||||
|
override fun apply(target: Project) {
|
||||||
|
with(target) {
|
||||||
|
with(pluginManager) {
|
||||||
|
apply("com.android.application")
|
||||||
|
apply("org.jetbrains.kotlin.android")
|
||||||
|
apply("org.jetbrains.kotlinx.kover")
|
||||||
|
}
|
||||||
|
|
||||||
|
extensions.configure<BaseAppModuleExtension> {
|
||||||
|
configureKotlinAndroid(this)
|
||||||
|
defaultConfig.targetSdk = Versions.TARGET_SDK_VERSION
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import com.android.build.gradle.LibraryExtension
|
||||||
|
import com.atridad.mealient.configureAndroidCompose
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.kotlin.dsl.configure
|
||||||
|
|
||||||
|
class AndroidLibraryComposeConventionPlugin : Plugin<Project> {
|
||||||
|
|
||||||
|
override fun apply(target: Project) {
|
||||||
|
with(target) {
|
||||||
|
pluginManager.apply("org.jetbrains.kotlin.plugin.compose")
|
||||||
|
|
||||||
|
extensions.configure<LibraryExtension> {
|
||||||
|
configureAndroidCompose(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import com.android.build.gradle.LibraryExtension
|
||||||
|
import com.atridad.mealient.Versions
|
||||||
|
import com.atridad.mealient.configureKotlinAndroid
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.kotlin.dsl.configure
|
||||||
|
|
||||||
|
class AndroidLibraryConventionPlugin : Plugin<Project> {
|
||||||
|
override fun apply(target: Project) {
|
||||||
|
with(target) {
|
||||||
|
with(pluginManager) {
|
||||||
|
apply("com.android.library")
|
||||||
|
apply("org.jetbrains.kotlin.android")
|
||||||
|
apply("org.jetbrains.kotlinx.kover")
|
||||||
|
}
|
||||||
|
|
||||||
|
extensions.configure<LibraryExtension> {
|
||||||
|
configureKotlinAndroid(this)
|
||||||
|
defaultConfig.targetSdk = Versions.TARGET_SDK_VERSION
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.atridad.mealient
|
||||||
|
|
||||||
|
import com.android.build.api.dsl.CommonExtension
|
||||||
|
import com.android.build.gradle.LibraryExtension
|
||||||
|
import com.android.build.gradle.internal.dsl.BaseAppModuleExtension
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.kotlin.dsl.dependencies
|
||||||
|
|
||||||
|
internal fun Project.configureAndroidCompose(
|
||||||
|
commonExtension: CommonExtension<*, *, *, *, *, *>,
|
||||||
|
) {
|
||||||
|
val variants = when (commonExtension) {
|
||||||
|
is BaseAppModuleExtension -> commonExtension.applicationVariants
|
||||||
|
is LibraryExtension -> commonExtension.libraryVariants
|
||||||
|
else -> error("Unsupported extension type")
|
||||||
|
}
|
||||||
|
|
||||||
|
commonExtension.apply {
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add compose-destinations generated code to Gradle source sets
|
||||||
|
variants.all {
|
||||||
|
kotlin.sourceSets {
|
||||||
|
getByName(name) {
|
||||||
|
kotlin.srcDir("build/generated/ksp/$name/kotlin")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
val bom = library("androidx-compose-bom")
|
||||||
|
add("implementation", platform(bom))
|
||||||
|
add("androidTestImplementation", platform(bom))
|
||||||
|
|
||||||
|
add("implementation", library("androidx-compose-material3"))
|
||||||
|
add("implementation", library("androidx-compose-ui-toolingPreview"))
|
||||||
|
add("implementation", library("androidx-compose-runtime-livedata"))
|
||||||
|
add("implementation", library("androidx-lifecycle-viewmodelCompose"))
|
||||||
|
add("implementation", library("google-accompanist-themeadapter-material3"))
|
||||||
|
add("debugImplementation", library("androidx-compose-ui-tooling"))
|
||||||
|
add("debugImplementation", library("androidx-compose-ui-testManifest"))
|
||||||
|
add("androidTestImplementation", library("androidx-compose-ui-testJunit"))
|
||||||
|
add("implementation", library("composeDestinations-core"))
|
||||||
|
add("ksp", library("composeDestinations-ksp"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.atridad.mealient
|
||||||
|
|
||||||
|
import org.gradle.api.Action
|
||||||
|
import org.gradle.api.NamedDomainObjectContainer
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.api.artifacts.MinimalExternalModuleDependency
|
||||||
|
import org.gradle.api.plugins.ExtensionAware
|
||||||
|
import org.gradle.api.provider.Provider
|
||||||
|
import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
|
||||||
|
import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet
|
||||||
|
|
||||||
|
internal val Project.kotlin: KotlinAndroidProjectExtension
|
||||||
|
get() = (this as ExtensionAware).extensions.getByName("kotlin") as KotlinAndroidProjectExtension
|
||||||
|
|
||||||
|
internal fun Project.kotlin(configure: Action<KotlinAndroidProjectExtension>): Unit =
|
||||||
|
(this as ExtensionAware).extensions.configure("kotlin", configure)
|
||||||
|
|
||||||
|
internal fun KotlinAndroidProjectExtension.sourceSets(configure: Action<NamedDomainObjectContainer<KotlinSourceSet>>): Unit =
|
||||||
|
(this as ExtensionAware).extensions.configure("sourceSets", configure)
|
||||||
|
|
||||||
|
internal fun Project.library(name: String): Provider<MinimalExternalModuleDependency> {
|
||||||
|
return libs.findLibrary(name).get()
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
@file:Suppress("UnstableApiUsage")
|
||||||
|
|
||||||
|
package com.atridad.mealient
|
||||||
|
|
||||||
|
import com.android.build.api.dsl.CommonExtension
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.kotlin.dsl.dependencies
|
||||||
|
|
||||||
|
internal fun Project.configureKotlinAndroid(
|
||||||
|
commonExtension: CommonExtension<*, *, *, *, *, *>,
|
||||||
|
) {
|
||||||
|
commonExtension.apply {
|
||||||
|
compileSdk = Versions.COMPILE_SDK_VERSION
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = Versions.MIN_SDK_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
lint {
|
||||||
|
disable += listOf(
|
||||||
|
"ObsoleteLintCustomCheck",
|
||||||
|
"IconMissingDensityFolder",
|
||||||
|
"MissingTranslation"
|
||||||
|
)
|
||||||
|
enable += listOf(
|
||||||
|
"ConvertToWebp",
|
||||||
|
"DuplicateStrings",
|
||||||
|
"EasterEgg",
|
||||||
|
"ExpensiveAssertion",
|
||||||
|
"IconExpectedSize",
|
||||||
|
"ImplicitSamInstance",
|
||||||
|
"InvalidPackage",
|
||||||
|
"KotlinPropertyAccess",
|
||||||
|
"LambdaLast",
|
||||||
|
"MinSdkTooLow",
|
||||||
|
"NegativeMargin",
|
||||||
|
"NoHardKeywords",
|
||||||
|
"Registered",
|
||||||
|
"RequiredSize",
|
||||||
|
"UnknownNullness",
|
||||||
|
"WrongThreadInterprocedural"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
testOptions {
|
||||||
|
unitTests {
|
||||||
|
isIncludeAndroidResources = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
getByName("debug") {
|
||||||
|
enableUnitTestCoverage = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
add("coreLibraryDesugaring", library("android-tools-desugar").get())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(17)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.atridad.mealient
|
||||||
|
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.api.artifacts.VersionCatalog
|
||||||
|
import org.gradle.api.artifacts.VersionCatalogsExtension
|
||||||
|
import org.gradle.kotlin.dsl.getByType
|
||||||
|
|
||||||
|
object Versions {
|
||||||
|
const val MIN_SDK_VERSION = 26
|
||||||
|
const val TARGET_SDK_VERSION = 34
|
||||||
|
const val COMPILE_SDK_VERSION = 36
|
||||||
|
}
|
||||||
|
|
||||||
|
val Project.libs: VersionCatalog
|
||||||
|
get() = extensions.getByType<VersionCatalogsExtension>().named("libs")
|
||||||
@@ -8,7 +8,7 @@ import org.gradle.kotlin.dsl.getByType
|
|||||||
object Versions {
|
object Versions {
|
||||||
const val MIN_SDK_VERSION = 26
|
const val MIN_SDK_VERSION = 26
|
||||||
const val TARGET_SDK_VERSION = 34
|
const val TARGET_SDK_VERSION = 34
|
||||||
const val COMPILE_SDK_VERSION = 34
|
const val COMPILE_SDK_VERSION = 36
|
||||||
}
|
}
|
||||||
|
|
||||||
val Project.libs: VersionCatalog
|
val Project.libs: VersionCatalog
|
||||||
|
|||||||
16
crowdin.yml
@@ -1,16 +0,0 @@
|
|||||||
"preserve_hierarchy": true
|
|
||||||
|
|
||||||
files: [
|
|
||||||
{
|
|
||||||
"source": "/app/src/main/res/values/strings.xml",
|
|
||||||
"translation": "/app/src/main/res/values-%two_letters_code%/strings.xml"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "/app/src/main/res/values/plurals.xml",
|
|
||||||
"translation": "/app/src/main/res/values-%two_letters_code%/plurals.xml"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "/features/shopping_lists/src/main/res/values/strings.xml",
|
|
||||||
"translation": "/features/shopping_lists/src/main/res/values-%two_letters_code%/strings.xml"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -2,15 +2,18 @@ package com.atridad.mealient.database
|
|||||||
|
|
||||||
import androidx.room.TypeConverter
|
import androidx.room.TypeConverter
|
||||||
import kotlinx.datetime.*
|
import kotlinx.datetime.*
|
||||||
|
import kotlin.time.ExperimentalTime
|
||||||
|
|
||||||
object RoomTypeConverters {
|
object RoomTypeConverters {
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
fun localDateTimeToTimestamp(localDateTime: LocalDateTime) =
|
fun localDateTimeToTimestamp(localDateTime: LocalDateTime) =
|
||||||
localDateTime.toInstant(TimeZone.UTC).toEpochMilliseconds()
|
localDateTime.toInstant(TimeZone.UTC).toEpochMilliseconds()
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
fun timestampToLocalDateTime(timestamp: Long) =
|
fun timestampToLocalDateTime(timestamp: Long) =
|
||||||
Instant.fromEpochMilliseconds(timestamp).toLocalDateTime(TimeZone.UTC)
|
kotlin.time.Instant.fromEpochMilliseconds(timestamp).toLocalDateTime(TimeZone.UTC)
|
||||||
|
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
fun localDateToTimeStamp(date: LocalDate) =
|
fun localDateToTimeStamp(date: LocalDate) =
|
||||||
|
|||||||
@@ -9,22 +9,21 @@ internal interface RecipeDao {
|
|||||||
@Query("SELECT * FROM recipe_summaries ORDER BY recipe_summaries_date_added DESC")
|
@Query("SELECT * FROM recipe_summaries ORDER BY recipe_summaries_date_added DESC")
|
||||||
fun queryRecipesByPages(): PagingSource<Int, RecipeSummaryEntity>
|
fun queryRecipesByPages(): PagingSource<Int, RecipeSummaryEntity>
|
||||||
|
|
||||||
@Query("SELECT * FROM recipe_summaries WHERE recipe_summaries_name LIKE '%' || :query || '%' ORDER BY recipe_summaries_date_added DESC")
|
@Query(
|
||||||
|
"SELECT * FROM recipe_summaries WHERE recipe_summaries_name LIKE '%' || :query || '%' ORDER BY recipe_summaries_date_added DESC"
|
||||||
|
)
|
||||||
fun queryRecipesByPages(query: String): PagingSource<Int, RecipeSummaryEntity>
|
fun queryRecipesByPages(query: String): PagingSource<Int, RecipeSummaryEntity>
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun insertRecipeSummaries(recipeSummaryEntity: Iterable<RecipeSummaryEntity>)
|
suspend fun insertRecipeSummaries(recipeSummaryEntity: Iterable<RecipeSummaryEntity>)
|
||||||
|
|
||||||
@Transaction
|
@Transaction @Query("DELETE FROM recipe_summaries") suspend fun removeAllRecipes()
|
||||||
@Query("DELETE FROM recipe_summaries")
|
|
||||||
suspend fun removeAllRecipes()
|
|
||||||
|
|
||||||
@Query("SELECT * FROM recipe_summaries ORDER BY recipe_summaries_date_added DESC")
|
@Query("SELECT * FROM recipe_summaries ORDER BY recipe_summaries_date_added DESC")
|
||||||
suspend fun queryAllRecipes(): List<RecipeSummaryEntity>
|
suspend fun queryAllRecipes(): List<RecipeSummaryEntity>
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertRecipe(recipe: RecipeEntity)
|
||||||
suspend fun insertRecipe(recipe: RecipeEntity)
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun insertRecipes(recipe: List<RecipeEntity>)
|
suspend fun insertRecipes(recipe: List<RecipeEntity>)
|
||||||
@@ -36,19 +35,25 @@ internal interface RecipeDao {
|
|||||||
suspend fun insertRecipeIngredients(ingredients: List<RecipeIngredientEntity>)
|
suspend fun insertRecipeIngredients(ingredients: List<RecipeIngredientEntity>)
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun insertIngredientToInstructionEntities(entities: List<RecipeIngredientToInstructionEntity>)
|
suspend fun insertIngredientToInstructionEntities(
|
||||||
|
entities: List<RecipeIngredientToInstructionEntity>
|
||||||
|
)
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
@SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) // The lint is wrong, the columns are actually used
|
@SuppressWarnings(
|
||||||
|
RoomWarnings.CURSOR_MISMATCH
|
||||||
|
) // The lint is wrong, the columns are actually used
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM recipe " +
|
"SELECT * FROM recipe " +
|
||||||
"JOIN recipe_summaries USING(recipe_id) " +
|
"JOIN recipe_summaries USING(recipe_id) " +
|
||||||
"LEFT JOIN recipe_ingredient USING(recipe_id) " +
|
"LEFT JOIN recipe_ingredient USING(recipe_id) " +
|
||||||
"LEFT JOIN recipe_instruction USING(recipe_id) " +
|
"LEFT JOIN recipe_instruction USING(recipe_id) " +
|
||||||
"LEFT JOIN recipe_ingredient_to_instruction USING(recipe_id) " +
|
"LEFT JOIN recipe_ingredient_to_instruction USING(recipe_id) " +
|
||||||
"WHERE recipe.recipe_id = :recipeId"
|
"WHERE recipe.recipe_id = :recipeId"
|
||||||
)
|
)
|
||||||
suspend fun queryFullRecipeInfo(recipeId: String): RecipeWithSummaryAndIngredientsAndInstructions?
|
suspend fun queryFullRecipeInfo(
|
||||||
|
recipeId: String
|
||||||
|
): RecipeWithSummaryAndIngredientsAndInstructions?
|
||||||
|
|
||||||
@Query("DELETE FROM recipe_ingredient WHERE recipe_id IN (:recipeIds)")
|
@Query("DELETE FROM recipe_ingredient WHERE recipe_id IN (:recipeIds)")
|
||||||
suspend fun deleteRecipeIngredients(vararg recipeIds: String)
|
suspend fun deleteRecipeIngredients(vararg recipeIds: String)
|
||||||
@@ -59,12 +64,18 @@ internal interface RecipeDao {
|
|||||||
@Query("DELETE FROM recipe_ingredient_to_instruction WHERE recipe_id IN (:recipeIds)")
|
@Query("DELETE FROM recipe_ingredient_to_instruction WHERE recipe_id IN (:recipeIds)")
|
||||||
suspend fun deleteRecipeIngredientToInstructions(vararg recipeIds: String)
|
suspend fun deleteRecipeIngredientToInstructions(vararg recipeIds: String)
|
||||||
|
|
||||||
@Query("UPDATE recipe_summaries SET recipe_summaries_is_favorite = 1 WHERE recipe_summaries_slug IN (:favorites)")
|
@Query(
|
||||||
|
"UPDATE recipe_summaries SET recipe_summaries_is_favorite = 1 WHERE recipe_summaries_slug IN (:favorites)"
|
||||||
|
)
|
||||||
suspend fun setFavorite(favorites: List<String>)
|
suspend fun setFavorite(favorites: List<String>)
|
||||||
|
|
||||||
@Query("UPDATE recipe_summaries SET recipe_summaries_is_favorite = 0 WHERE recipe_summaries_slug NOT IN (:favorites)")
|
@Query("UPDATE recipe_summaries SET recipe_summaries_is_favorite = 0")
|
||||||
|
suspend fun setAllNonFavorite()
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"UPDATE recipe_summaries SET recipe_summaries_is_favorite = 0 WHERE recipe_summaries_slug NOT IN (:favorites)"
|
||||||
|
)
|
||||||
suspend fun setNonFavorite(favorites: List<String>)
|
suspend fun setNonFavorite(favorites: List<String>)
|
||||||
|
|
||||||
@Delete
|
@Delete suspend fun deleteRecipe(entity: RecipeSummaryEntity)
|
||||||
suspend fun deleteRecipe(entity: RecipeSummaryEntity)
|
|
||||||
}
|
}
|
||||||
@@ -12,10 +12,12 @@ import com.atridad.mealient.database.recipe.entity.RecipeWithSummaryAndIngredien
|
|||||||
import com.atridad.mealient.logging.Logger
|
import com.atridad.mealient.logging.Logger
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
internal class RecipeStorageImpl @Inject constructor(
|
internal class RecipeStorageImpl
|
||||||
private val db: AppDb,
|
@Inject
|
||||||
private val logger: Logger,
|
constructor(
|
||||||
private val recipeDao: RecipeDao,
|
private val db: AppDb,
|
||||||
|
private val logger: Logger,
|
||||||
|
private val recipeDao: RecipeDao,
|
||||||
) : RecipeStorage {
|
) : RecipeStorage {
|
||||||
|
|
||||||
override suspend fun saveRecipes(recipes: List<RecipeSummaryEntity>) {
|
override suspend fun saveRecipes(recipes: List<RecipeSummaryEntity>) {
|
||||||
@@ -43,12 +45,14 @@ internal class RecipeStorageImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun saveRecipeInfo(
|
override suspend fun saveRecipeInfo(
|
||||||
recipe: RecipeEntity,
|
recipe: RecipeEntity,
|
||||||
ingredients: List<RecipeIngredientEntity>,
|
ingredients: List<RecipeIngredientEntity>,
|
||||||
instructions: List<RecipeInstructionEntity>,
|
instructions: List<RecipeInstructionEntity>,
|
||||||
ingredientToInstruction: List<RecipeIngredientToInstructionEntity>,
|
ingredientToInstruction: List<RecipeIngredientToInstructionEntity>,
|
||||||
) {
|
) {
|
||||||
logger.v { "saveRecipeInfo() called with: recipe = $recipe, ingredients = $ingredients, instructions = $instructions, ingredientToInstructions = $ingredientToInstruction" }
|
logger.v {
|
||||||
|
"saveRecipeInfo() called with: recipe = $recipe, ingredients = $ingredients, instructions = $instructions, ingredientToInstructions = $ingredientToInstruction"
|
||||||
|
}
|
||||||
db.withTransaction {
|
db.withTransaction {
|
||||||
recipeDao.insertRecipe(recipe)
|
recipeDao.insertRecipe(recipe)
|
||||||
|
|
||||||
@@ -63,7 +67,9 @@ internal class RecipeStorageImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun queryRecipeInfo(recipeId: String): RecipeWithSummaryAndIngredientsAndInstructions? {
|
override suspend fun queryRecipeInfo(
|
||||||
|
recipeId: String
|
||||||
|
): RecipeWithSummaryAndIngredientsAndInstructions? {
|
||||||
logger.v { "queryRecipeInfo() called with: recipeId = $recipeId" }
|
logger.v { "queryRecipeInfo() called with: recipeId = $recipeId" }
|
||||||
val fullRecipeInfo = recipeDao.queryFullRecipeInfo(recipeId)
|
val fullRecipeInfo = recipeDao.queryFullRecipeInfo(recipeId)
|
||||||
logger.v { "queryRecipeInfo() returned: $fullRecipeInfo" }
|
logger.v { "queryRecipeInfo() returned: $fullRecipeInfo" }
|
||||||
@@ -73,8 +79,12 @@ internal class RecipeStorageImpl @Inject constructor(
|
|||||||
override suspend fun updateFavoriteRecipes(favorites: List<String>) {
|
override suspend fun updateFavoriteRecipes(favorites: List<String>) {
|
||||||
logger.v { "updateFavoriteRecipes() called with: favorites = $favorites" }
|
logger.v { "updateFavoriteRecipes() called with: favorites = $favorites" }
|
||||||
db.withTransaction {
|
db.withTransaction {
|
||||||
recipeDao.setFavorite(favorites)
|
if (favorites.isNotEmpty()) {
|
||||||
recipeDao.setNonFavorite(favorites)
|
recipeDao.setFavorite(favorites)
|
||||||
|
recipeDao.setNonFavorite(favorites)
|
||||||
|
} else {
|
||||||
|
recipeDao.setAllNonFavorite()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,47 +12,51 @@ import com.atridad.mealient.datasource.models.GetShoppingListItemResponse
|
|||||||
import com.atridad.mealient.datasource.models.GetShoppingListResponse
|
import com.atridad.mealient.datasource.models.GetShoppingListResponse
|
||||||
import com.atridad.mealient.datasource.models.GetShoppingListsResponse
|
import com.atridad.mealient.datasource.models.GetShoppingListsResponse
|
||||||
import com.atridad.mealient.datasource.models.GetUnitsResponse
|
import com.atridad.mealient.datasource.models.GetUnitsResponse
|
||||||
|
import com.atridad.mealient.datasource.models.GetUserFavoritesResponse
|
||||||
import com.atridad.mealient.datasource.models.GetUserInfoResponse
|
import com.atridad.mealient.datasource.models.GetUserInfoResponse
|
||||||
import com.atridad.mealient.datasource.models.ParseRecipeURLRequest
|
import com.atridad.mealient.datasource.models.ParseRecipeURLRequest
|
||||||
import com.atridad.mealient.datasource.models.UpdateRecipeRequest
|
import com.atridad.mealient.datasource.models.UpdateRecipeRequest
|
||||||
|
import com.atridad.mealient.datasource.models.UserProfileResponse
|
||||||
|
import com.atridad.mealient.datasource.models.UpdateUserProfileRequest
|
||||||
|
import com.atridad.mealient.datasource.models.UpdateUserResponse
|
||||||
|
import com.atridad.mealient.datasource.models.ChangePasswordRequest
|
||||||
|
import com.atridad.mealient.datasource.models.UpdateProfileImageRequest
|
||||||
import com.atridad.mealient.datasource.models.VersionResponse
|
import com.atridad.mealient.datasource.models.VersionResponse
|
||||||
|
|
||||||
interface MealieDataSource {
|
interface MealieDataSource {
|
||||||
|
|
||||||
suspend fun createRecipe(
|
suspend fun createRecipe(
|
||||||
recipe: CreateRecipeRequest,
|
recipe: CreateRecipeRequest,
|
||||||
): String
|
): String
|
||||||
|
|
||||||
suspend fun updateRecipe(
|
suspend fun updateRecipe(
|
||||||
slug: String,
|
slug: String,
|
||||||
recipe: UpdateRecipeRequest,
|
recipe: UpdateRecipeRequest,
|
||||||
): GetRecipeResponse
|
): GetRecipeResponse
|
||||||
|
|
||||||
/**
|
/** Tries to acquire authentication token using the provided credentials */
|
||||||
* Tries to acquire authentication token using the provided credentials
|
|
||||||
*/
|
|
||||||
suspend fun authenticate(
|
suspend fun authenticate(
|
||||||
username: String,
|
username: String,
|
||||||
password: String,
|
password: String,
|
||||||
): String
|
): String
|
||||||
|
|
||||||
suspend fun getVersionInfo(baseURL: String): VersionResponse
|
suspend fun getVersionInfo(baseURL: String): VersionResponse
|
||||||
|
|
||||||
suspend fun requestRecipes(
|
suspend fun requestRecipes(
|
||||||
page: Int,
|
page: Int,
|
||||||
perPage: Int,
|
perPage: Int,
|
||||||
): List<GetRecipeSummaryResponse>
|
): List<GetRecipeSummaryResponse>
|
||||||
|
|
||||||
suspend fun requestRecipeInfo(
|
suspend fun requestRecipeInfo(
|
||||||
slug: String,
|
slug: String,
|
||||||
): GetRecipeResponse
|
): GetRecipeResponse
|
||||||
|
|
||||||
suspend fun parseRecipeFromURL(
|
suspend fun parseRecipeFromURL(
|
||||||
request: ParseRecipeURLRequest,
|
request: ParseRecipeURLRequest,
|
||||||
): String
|
): String
|
||||||
|
|
||||||
suspend fun createApiToken(
|
suspend fun createApiToken(
|
||||||
request: CreateApiTokenRequest,
|
request: CreateApiTokenRequest,
|
||||||
): CreateApiTokenResponse
|
): CreateApiTokenResponse
|
||||||
|
|
||||||
suspend fun requestUserInfo(): GetUserInfoResponse
|
suspend fun requestUserInfo(): GetUserInfoResponse
|
||||||
@@ -82,4 +86,15 @@ interface MealieDataSource {
|
|||||||
suspend fun deleteShoppingList(id: String)
|
suspend fun deleteShoppingList(id: String)
|
||||||
|
|
||||||
suspend fun updateShoppingListName(id: String, name: String)
|
suspend fun updateShoppingListName(id: String, name: String)
|
||||||
|
|
||||||
|
suspend fun getUserFavoritesAlternative(userId: String): GetUserFavoritesResponse
|
||||||
|
|
||||||
|
// User Profile Management
|
||||||
|
suspend fun getUserProfile(): UserProfileResponse
|
||||||
|
|
||||||
|
suspend fun updateUserProfile(userId: String, request: UpdateUserProfileRequest): UpdateUserResponse
|
||||||
|
|
||||||
|
suspend fun changePassword(request: ChangePasswordRequest)
|
||||||
|
|
||||||
|
suspend fun updateProfileImage(userId: String, request: UpdateProfileImageRequest)
|
||||||
}
|
}
|
||||||
@@ -12,10 +12,16 @@ import com.atridad.mealient.datasource.models.GetShoppingListResponse
|
|||||||
import com.atridad.mealient.datasource.models.GetShoppingListsResponse
|
import com.atridad.mealient.datasource.models.GetShoppingListsResponse
|
||||||
import com.atridad.mealient.datasource.models.GetTokenResponse
|
import com.atridad.mealient.datasource.models.GetTokenResponse
|
||||||
import com.atridad.mealient.datasource.models.GetUnitsResponse
|
import com.atridad.mealient.datasource.models.GetUnitsResponse
|
||||||
|
import com.atridad.mealient.datasource.models.GetUserFavoritesResponse
|
||||||
import com.atridad.mealient.datasource.models.GetUserInfoResponse
|
import com.atridad.mealient.datasource.models.GetUserInfoResponse
|
||||||
import com.atridad.mealient.datasource.models.ParseRecipeURLRequest
|
import com.atridad.mealient.datasource.models.ParseRecipeURLRequest
|
||||||
import com.atridad.mealient.datasource.models.UpdateRecipeRequest
|
import com.atridad.mealient.datasource.models.UpdateRecipeRequest
|
||||||
import com.atridad.mealient.datasource.models.VersionResponse
|
import com.atridad.mealient.datasource.models.VersionResponse
|
||||||
|
import com.atridad.mealient.datasource.models.UserProfileResponse
|
||||||
|
import com.atridad.mealient.datasource.models.UpdateUserProfileRequest
|
||||||
|
import com.atridad.mealient.datasource.models.UpdateUserResponse
|
||||||
|
import com.atridad.mealient.datasource.models.ChangePasswordRequest
|
||||||
|
import com.atridad.mealient.datasource.models.UpdateProfileImageRequest
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
internal interface MealieService {
|
internal interface MealieService {
|
||||||
@@ -25,8 +31,8 @@ internal interface MealieService {
|
|||||||
suspend fun createRecipe(addRecipeRequest: CreateRecipeRequest): String
|
suspend fun createRecipe(addRecipeRequest: CreateRecipeRequest): String
|
||||||
|
|
||||||
suspend fun updateRecipe(
|
suspend fun updateRecipe(
|
||||||
addRecipeRequest: UpdateRecipeRequest,
|
addRecipeRequest: UpdateRecipeRequest,
|
||||||
slug: String,
|
slug: String,
|
||||||
): GetRecipeResponse
|
): GetRecipeResponse
|
||||||
|
|
||||||
suspend fun getVersion(baseURL: String): VersionResponse
|
suspend fun getVersion(baseURL: String): VersionResponse
|
||||||
@@ -69,5 +75,16 @@ internal interface MealieService {
|
|||||||
|
|
||||||
suspend fun updateShoppingList(id: String, request: JsonElement)
|
suspend fun updateShoppingList(id: String, request: JsonElement)
|
||||||
|
|
||||||
suspend fun getShoppingListJson(id: String) : JsonElement
|
suspend fun getShoppingListJson(id: String): JsonElement
|
||||||
|
|
||||||
|
suspend fun getUserFavoritesAlternative(userId: String): GetUserFavoritesResponse
|
||||||
|
|
||||||
|
// User Profile Management
|
||||||
|
suspend fun getUserProfile(): UserProfileResponse
|
||||||
|
|
||||||
|
suspend fun updateUserProfile(userId: String, request: UpdateUserProfileRequest): UpdateUserResponse
|
||||||
|
|
||||||
|
suspend fun changePassword(request: ChangePasswordRequest)
|
||||||
|
|
||||||
|
suspend fun updateProfileImage(userId: String, request: UpdateProfileImageRequest)
|
||||||
}
|
}
|
||||||
@@ -17,258 +17,357 @@ import com.atridad.mealient.datasource.models.GetShoppingListItemResponse
|
|||||||
import com.atridad.mealient.datasource.models.GetShoppingListResponse
|
import com.atridad.mealient.datasource.models.GetShoppingListResponse
|
||||||
import com.atridad.mealient.datasource.models.GetShoppingListsResponse
|
import com.atridad.mealient.datasource.models.GetShoppingListsResponse
|
||||||
import com.atridad.mealient.datasource.models.GetUnitsResponse
|
import com.atridad.mealient.datasource.models.GetUnitsResponse
|
||||||
|
import com.atridad.mealient.datasource.models.GetUserFavoritesResponse
|
||||||
import com.atridad.mealient.datasource.models.GetUserInfoResponse
|
import com.atridad.mealient.datasource.models.GetUserInfoResponse
|
||||||
import com.atridad.mealient.datasource.models.ParseRecipeURLRequest
|
import com.atridad.mealient.datasource.models.ParseRecipeURLRequest
|
||||||
import com.atridad.mealient.datasource.models.UpdateRecipeRequest
|
import com.atridad.mealient.datasource.models.UpdateRecipeRequest
|
||||||
|
import com.atridad.mealient.datasource.models.UserProfileResponse
|
||||||
|
import com.atridad.mealient.datasource.models.UpdateUserProfileRequest
|
||||||
|
import com.atridad.mealient.datasource.models.UpdateUserResponse
|
||||||
|
import com.atridad.mealient.datasource.models.ChangePasswordRequest
|
||||||
|
import com.atridad.mealient.datasource.models.UpdateProfileImageRequest
|
||||||
import com.atridad.mealient.datasource.models.VersionResponse
|
import com.atridad.mealient.datasource.models.VersionResponse
|
||||||
import io.ktor.client.call.NoTransformationFoundException
|
import io.ktor.client.call.NoTransformationFoundException
|
||||||
import io.ktor.client.call.body
|
import io.ktor.client.call.body
|
||||||
import io.ktor.client.plugins.ResponseException
|
import io.ktor.client.plugins.ResponseException
|
||||||
|
import java.net.SocketException
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
|
import javax.inject.Inject
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
import java.net.SocketException
|
|
||||||
import java.net.SocketTimeoutException
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
internal class MealieDataSourceImpl @Inject constructor(
|
internal class MealieDataSourceImpl
|
||||||
private val networkRequestWrapper: NetworkRequestWrapper,
|
@Inject
|
||||||
private val service: MealieService,
|
constructor(
|
||||||
|
private val networkRequestWrapper: NetworkRequestWrapper,
|
||||||
|
private val service: MealieService,
|
||||||
) : MealieDataSource {
|
) : MealieDataSource {
|
||||||
|
|
||||||
override suspend fun createRecipe(
|
override suspend fun createRecipe(
|
||||||
recipe: CreateRecipeRequest,
|
recipe: CreateRecipeRequest,
|
||||||
): String = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
): String =
|
||||||
block = { service.createRecipe(recipe) },
|
networkRequestWrapper
|
||||||
logMethod = { "createRecipe" },
|
.makeCallAndHandleUnauthorized(
|
||||||
logParameters = { "recipe = $recipe" }
|
block = { service.createRecipe(recipe) },
|
||||||
).trim('"')
|
logMethod = { "createRecipe" },
|
||||||
|
logParameters = { "recipe = $recipe" }
|
||||||
|
)
|
||||||
|
.trim('"')
|
||||||
|
|
||||||
override suspend fun updateRecipe(
|
override suspend fun updateRecipe(
|
||||||
slug: String,
|
slug: String,
|
||||||
recipe: UpdateRecipeRequest,
|
recipe: UpdateRecipeRequest,
|
||||||
): GetRecipeResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
): GetRecipeResponse =
|
||||||
block = { service.updateRecipe(recipe, slug) },
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
logMethod = { "updateRecipe" },
|
block = { service.updateRecipe(recipe, slug) },
|
||||||
logParameters = { "slug = $slug, recipe = $recipe" }
|
logMethod = { "updateRecipe" },
|
||||||
)
|
logParameters = { "slug = $slug, recipe = $recipe" }
|
||||||
|
)
|
||||||
|
|
||||||
override suspend fun authenticate(
|
override suspend fun authenticate(
|
||||||
username: String,
|
username: String,
|
||||||
password: String,
|
password: String,
|
||||||
): String = networkRequestWrapper.makeCall(
|
): String =
|
||||||
block = { service.getToken(username, password) },
|
networkRequestWrapper
|
||||||
logMethod = { "authenticate" },
|
.makeCall(
|
||||||
logParameters = { "username = $username, password = $password" }
|
block = { service.getToken(username, password) },
|
||||||
).map { it.accessToken }.getOrElse {
|
logMethod = { "authenticate" },
|
||||||
val errorDetail = (it as? ResponseException)?.response?.body<ErrorDetail>() ?: throw it
|
logParameters = { "username = $username, password = $password" }
|
||||||
throw if (errorDetail.detail == "Unauthorized") NetworkError.Unauthorized(it) else it
|
)
|
||||||
}
|
.map { it.accessToken }
|
||||||
|
.getOrElse {
|
||||||
|
val errorDetail =
|
||||||
|
(it as? ResponseException)?.response?.body<ErrorDetail>()
|
||||||
|
?: throw it
|
||||||
|
throw if (errorDetail.detail == "Unauthorized")
|
||||||
|
NetworkError.Unauthorized(it)
|
||||||
|
else it
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getVersionInfo(baseURL: String): VersionResponse =
|
override suspend fun getVersionInfo(baseURL: String): VersionResponse =
|
||||||
networkRequestWrapper.makeCall(
|
networkRequestWrapper.makeCall(
|
||||||
block = { service.getVersion(baseURL) },
|
block = { service.getVersion(baseURL) },
|
||||||
logMethod = { "getVersionInfo" },
|
logMethod = { "getVersionInfo" },
|
||||||
logParameters = { "baseURL = $baseURL" }
|
logParameters = { "baseURL = $baseURL" }
|
||||||
).getOrElse {
|
)
|
||||||
throw when (it) {
|
.getOrElse {
|
||||||
is ResponseException, is NoTransformationFoundException -> NetworkError.NotMealie(it)
|
throw when (it) {
|
||||||
is SocketTimeoutException, is SocketException -> NetworkError.NoServerConnection(it)
|
is ResponseException, is NoTransformationFoundException ->
|
||||||
else -> NetworkError.MalformedUrl(it)
|
NetworkError.NotMealie(it)
|
||||||
}
|
is SocketTimeoutException, is SocketException ->
|
||||||
}
|
NetworkError.NoServerConnection(it)
|
||||||
|
else -> NetworkError.MalformedUrl(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun requestRecipes(
|
override suspend fun requestRecipes(
|
||||||
page: Int,
|
page: Int,
|
||||||
perPage: Int,
|
perPage: Int,
|
||||||
): List<GetRecipeSummaryResponse> = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
): List<GetRecipeSummaryResponse> {
|
||||||
block = { service.getRecipeSummary(page, perPage) },
|
val response =
|
||||||
logMethod = { "requestRecipes" },
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
logParameters = { "page = $page, perPage = $perPage" }
|
block = { service.getRecipeSummary(page, perPage) },
|
||||||
).items
|
logMethod = { "requestRecipes" },
|
||||||
|
logParameters = { "page = $page, perPage = $perPage" }
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.items
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun requestRecipeInfo(
|
override suspend fun requestRecipeInfo(
|
||||||
slug: String,
|
slug: String,
|
||||||
): GetRecipeResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
): GetRecipeResponse =
|
||||||
block = { service.getRecipe(slug) },
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
logMethod = { "requestRecipeInfo" },
|
block = { service.getRecipe(slug) },
|
||||||
logParameters = { "slug = $slug" }
|
logMethod = { "requestRecipeInfo" },
|
||||||
)
|
logParameters = { "slug = $slug" }
|
||||||
|
)
|
||||||
|
|
||||||
override suspend fun parseRecipeFromURL(
|
override suspend fun parseRecipeFromURL(
|
||||||
request: ParseRecipeURLRequest,
|
request: ParseRecipeURLRequest,
|
||||||
): String = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
): String =
|
||||||
block = { service.createRecipeFromURL(request) },
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
logMethod = { "parseRecipeFromURL" },
|
block = { service.createRecipeFromURL(request) },
|
||||||
logParameters = { "request = $request" }
|
logMethod = { "parseRecipeFromURL" },
|
||||||
)
|
logParameters = { "request = $request" }
|
||||||
|
)
|
||||||
|
|
||||||
override suspend fun createApiToken(
|
override suspend fun createApiToken(
|
||||||
request: CreateApiTokenRequest,
|
request: CreateApiTokenRequest,
|
||||||
): CreateApiTokenResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
): CreateApiTokenResponse =
|
||||||
block = { service.createApiToken(request) },
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
logMethod = { "createApiToken" },
|
block = { service.createApiToken(request) },
|
||||||
logParameters = { "request = $request" }
|
logMethod = { "createApiToken" },
|
||||||
)
|
logParameters = { "request = $request" }
|
||||||
|
)
|
||||||
|
|
||||||
override suspend fun requestUserInfo(): GetUserInfoResponse {
|
override suspend fun requestUserInfo(): GetUserInfoResponse {
|
||||||
return networkRequestWrapper.makeCallAndHandleUnauthorized(
|
val response =
|
||||||
block = { service.getUserSelfInfo() },
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
logMethod = { "requestUserInfo" },
|
block = { service.getUserSelfInfo() },
|
||||||
)
|
logMethod = { "requestUserInfo" },
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun removeFavoriteRecipe(
|
override suspend fun removeFavoriteRecipe(
|
||||||
userId: String,
|
userId: String,
|
||||||
recipeSlug: String,
|
recipeSlug: String,
|
||||||
): Unit = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
): Unit {
|
||||||
block = { service.removeFavoriteRecipe(userId, recipeSlug) },
|
|
||||||
logMethod = { "removeFavoriteRecipe" },
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
logParameters = { "userId = $userId, recipeSlug = $recipeSlug" }
|
block = { service.removeFavoriteRecipe(userId, recipeSlug) },
|
||||||
)
|
logMethod = { "removeFavoriteRecipe" },
|
||||||
|
logParameters = { "userId = $userId, recipeSlug = $recipeSlug" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun addFavoriteRecipe(
|
override suspend fun addFavoriteRecipe(
|
||||||
userId: String,
|
userId: String,
|
||||||
recipeSlug: String,
|
recipeSlug: String,
|
||||||
): Unit = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
): Unit {
|
||||||
block = { service.addFavoriteRecipe(userId, recipeSlug) },
|
|
||||||
logMethod = { "addFavoriteRecipe" },
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
logParameters = { "userId = $userId, recipeSlug = $recipeSlug" }
|
block = { service.addFavoriteRecipe(userId, recipeSlug) },
|
||||||
)
|
logMethod = { "addFavoriteRecipe" },
|
||||||
|
logParameters = { "userId = $userId, recipeSlug = $recipeSlug" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun deleteRecipe(
|
override suspend fun deleteRecipe(
|
||||||
slug: String,
|
slug: String,
|
||||||
): Unit = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
): Unit =
|
||||||
block = { service.deleteRecipe(slug) },
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
logMethod = { "deleteRecipe" },
|
block = { service.deleteRecipe(slug) },
|
||||||
logParameters = { "slug = $slug" }
|
logMethod = { "deleteRecipe" },
|
||||||
)
|
logParameters = { "slug = $slug" }
|
||||||
|
)
|
||||||
|
|
||||||
override suspend fun getShoppingLists(
|
override suspend fun getShoppingLists(
|
||||||
page: Int,
|
page: Int,
|
||||||
perPage: Int,
|
perPage: Int,
|
||||||
): GetShoppingListsResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
): GetShoppingListsResponse =
|
||||||
block = { service.getShoppingLists(page, perPage) },
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
logMethod = { "getShoppingLists" },
|
block = { service.getShoppingLists(page, perPage) },
|
||||||
logParameters = { "page = $page, perPage = $perPage" }
|
logMethod = { "getShoppingLists" },
|
||||||
)
|
logParameters = { "page = $page, perPage = $perPage" }
|
||||||
|
)
|
||||||
|
|
||||||
override suspend fun getShoppingList(
|
override suspend fun getShoppingList(
|
||||||
id: String,
|
id: String,
|
||||||
): GetShoppingListResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
): GetShoppingListResponse =
|
||||||
block = { service.getShoppingList(id) },
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
logMethod = { "getShoppingList" },
|
block = { service.getShoppingList(id) },
|
||||||
logParameters = { "id = $id" }
|
logMethod = { "getShoppingList" },
|
||||||
)
|
logParameters = { "id = $id" }
|
||||||
|
)
|
||||||
|
|
||||||
private suspend fun getShoppingListItem(
|
private suspend fun getShoppingListItem(
|
||||||
id: String,
|
id: String,
|
||||||
): JsonElement = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
): JsonElement =
|
||||||
block = { service.getShoppingListItem(id) },
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
logMethod = { "getShoppingListItem" },
|
block = { service.getShoppingListItem(id) },
|
||||||
logParameters = { "id = $id" }
|
logMethod = { "getShoppingListItem" },
|
||||||
)
|
logParameters = { "id = $id" }
|
||||||
|
)
|
||||||
|
|
||||||
private suspend fun updateShoppingListItem(
|
private suspend fun updateShoppingListItem(
|
||||||
id: String,
|
id: String,
|
||||||
request: JsonElement,
|
request: JsonElement,
|
||||||
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
) =
|
||||||
block = { service.updateShoppingListItem(id, request) },
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
logMethod = { "updateShoppingListItem" },
|
block = { service.updateShoppingListItem(id, request) },
|
||||||
logParameters = { "id = $id, request = $request" }
|
logMethod = { "updateShoppingListItem" },
|
||||||
)
|
logParameters = { "id = $id, request = $request" }
|
||||||
|
)
|
||||||
|
|
||||||
override suspend fun deleteShoppingListItem(
|
override suspend fun deleteShoppingListItem(
|
||||||
id: String,
|
id: String,
|
||||||
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
) =
|
||||||
block = { service.deleteShoppingListItem(id) },
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
logMethod = { "deleteShoppingListItem" },
|
block = { service.deleteShoppingListItem(id) },
|
||||||
logParameters = { "id = $id" }
|
logMethod = { "deleteShoppingListItem" },
|
||||||
)
|
logParameters = { "id = $id" }
|
||||||
|
)
|
||||||
|
|
||||||
override suspend fun updateShoppingListItem(
|
override suspend fun updateShoppingListItem(
|
||||||
item: GetShoppingListItemResponse,
|
item: GetShoppingListItemResponse,
|
||||||
) {
|
) {
|
||||||
// Has to be done in two steps because we can't specify only the changed fields
|
// Has to be done in two steps because we can't specify only the changed fields
|
||||||
val remoteItem = getShoppingListItem(item.id)
|
val remoteItem = getShoppingListItem(item.id)
|
||||||
val updatedItem = remoteItem.jsonObject.toMutableMap().apply {
|
val updatedItem =
|
||||||
put("checked", JsonPrimitive(item.checked))
|
remoteItem.jsonObject.toMutableMap().apply {
|
||||||
put("isFood", JsonPrimitive(item.isFood))
|
put("checked", JsonPrimitive(item.checked))
|
||||||
put("note", JsonPrimitive(item.note))
|
put("isFood", JsonPrimitive(item.isFood))
|
||||||
put("quantity", JsonPrimitive(item.quantity))
|
put("note", JsonPrimitive(item.note))
|
||||||
put("foodId", JsonPrimitive(item.food?.id))
|
put("quantity", JsonPrimitive(item.quantity))
|
||||||
put("unitId", JsonPrimitive(item.unit?.id))
|
put("foodId", JsonPrimitive(item.food?.id))
|
||||||
remove("unit")
|
put("unitId", JsonPrimitive(item.unit?.id))
|
||||||
remove("food")
|
remove("unit")
|
||||||
}
|
remove("food")
|
||||||
|
}
|
||||||
updateShoppingListItem(item.id, JsonObject(updatedItem))
|
updateShoppingListItem(item.id, JsonObject(updatedItem))
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getFoods(): GetFoodsResponse {
|
override suspend fun getFoods(): GetFoodsResponse {
|
||||||
return networkRequestWrapper.makeCallAndHandleUnauthorized(
|
return networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
block = { service.getFoods(perPage = -1) },
|
block = { service.getFoods(perPage = -1) },
|
||||||
logMethod = { "getFoods" },
|
logMethod = { "getFoods" },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getUnits(): GetUnitsResponse {
|
override suspend fun getUnits(): GetUnitsResponse {
|
||||||
return networkRequestWrapper.makeCallAndHandleUnauthorized(
|
return networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
block = { service.getUnits(perPage = -1) },
|
block = { service.getUnits(perPage = -1) },
|
||||||
logMethod = { "getUnits" },
|
logMethod = { "getUnits" },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun addShoppingListItem(
|
override suspend fun addShoppingListItem(
|
||||||
request: CreateShoppingListItemRequest,
|
request: CreateShoppingListItemRequest,
|
||||||
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
) =
|
||||||
block = { service.createShoppingListItem(request) },
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
logMethod = { "addShoppingListItem" },
|
block = { service.createShoppingListItem(request) },
|
||||||
logParameters = { "request = $request" }
|
logMethod = { "addShoppingListItem" },
|
||||||
)
|
logParameters = { "request = $request" }
|
||||||
|
)
|
||||||
|
|
||||||
override suspend fun addShoppingList(
|
override suspend fun addShoppingList(
|
||||||
request: CreateShoppingListRequest,
|
request: CreateShoppingListRequest,
|
||||||
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
) =
|
||||||
block = { service.createShoppingList(request) },
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
logMethod = { "createShoppingList" },
|
block = { service.createShoppingList(request) },
|
||||||
logParameters = { "request = $request" }
|
logMethod = { "createShoppingList" },
|
||||||
)
|
logParameters = { "request = $request" }
|
||||||
|
)
|
||||||
|
|
||||||
private suspend fun updateShoppingList(
|
private suspend fun updateShoppingList(
|
||||||
id: String,
|
id: String,
|
||||||
request: JsonElement,
|
request: JsonElement,
|
||||||
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
) =
|
||||||
block = { service.updateShoppingList(id, request) },
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
logMethod = { "updateShoppingList" },
|
block = { service.updateShoppingList(id, request) },
|
||||||
logParameters = { "id = $id, request = $request" }
|
logMethod = { "updateShoppingList" },
|
||||||
)
|
logParameters = { "id = $id, request = $request" }
|
||||||
|
)
|
||||||
|
|
||||||
private suspend fun getShoppingListJson(
|
private suspend fun getShoppingListJson(
|
||||||
id: String,
|
id: String,
|
||||||
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
) =
|
||||||
block = { service.getShoppingListJson(id) },
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
logMethod = { "getShoppingListJson" },
|
block = { service.getShoppingListJson(id) },
|
||||||
logParameters = { "id = $id" }
|
logMethod = { "getShoppingListJson" },
|
||||||
)
|
logParameters = { "id = $id" }
|
||||||
|
)
|
||||||
|
|
||||||
override suspend fun deleteShoppingList(
|
override suspend fun deleteShoppingList(
|
||||||
id: String,
|
id: String,
|
||||||
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
) =
|
||||||
block = { service.deleteShoppingList(id) },
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
logMethod = { "deleteShoppingList" },
|
block = { service.deleteShoppingList(id) },
|
||||||
logParameters = { "id = $id" }
|
logMethod = { "deleteShoppingList" },
|
||||||
)
|
logParameters = { "id = $id" }
|
||||||
|
)
|
||||||
|
|
||||||
override suspend fun updateShoppingListName(
|
override suspend fun updateShoppingListName(id: String, name: String) {
|
||||||
id: String,
|
|
||||||
name: String
|
|
||||||
) {
|
|
||||||
// Has to be done in two steps because we can't specify only the changed fields
|
// Has to be done in two steps because we can't specify only the changed fields
|
||||||
val remoteItem = getShoppingListJson(id)
|
val remoteItem = getShoppingListJson(id)
|
||||||
val updatedItem = remoteItem.jsonObject.toMutableMap().apply {
|
val updatedItem =
|
||||||
put("name", JsonPrimitive(name))
|
remoteItem
|
||||||
}.let(::JsonObject)
|
.jsonObject
|
||||||
|
.toMutableMap()
|
||||||
|
.apply { put("name", JsonPrimitive(name)) }
|
||||||
|
.let(::JsonObject)
|
||||||
updateShoppingList(id, updatedItem)
|
updateShoppingList(id, updatedItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getUserFavoritesAlternative(userId: String): GetUserFavoritesResponse {
|
||||||
|
|
||||||
|
val response =
|
||||||
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
|
block = { service.getUserFavoritesAlternative(userId) },
|
||||||
|
logMethod = { "getUserFavoritesAlternative" },
|
||||||
|
logParameters = { "userId = $userId" }
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
// User Profile Management
|
||||||
|
override suspend fun getUserProfile(): UserProfileResponse {
|
||||||
|
val response = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
|
block = { service.getUserProfile() },
|
||||||
|
logMethod = { "getUserProfile" },
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateUserProfile(userId: String, request: UpdateUserProfileRequest): UpdateUserResponse {
|
||||||
|
val response = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
|
block = { service.updateUserProfile(userId, request) },
|
||||||
|
logMethod = { "updateUserProfile" },
|
||||||
|
logParameters = { "userId = $userId" }
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun changePassword(request: ChangePasswordRequest) {
|
||||||
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
|
block = { service.changePassword(request) },
|
||||||
|
logMethod = { "changePassword" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateProfileImage(userId: String, request: UpdateProfileImageRequest) {
|
||||||
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
|
block = { service.updateProfileImage(userId, request) },
|
||||||
|
logMethod = { "updateProfileImage" },
|
||||||
|
logParameters = { "userId = $userId" }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,33 +14,45 @@ import com.atridad.mealient.datasource.models.GetShoppingListResponse
|
|||||||
import com.atridad.mealient.datasource.models.GetShoppingListsResponse
|
import com.atridad.mealient.datasource.models.GetShoppingListsResponse
|
||||||
import com.atridad.mealient.datasource.models.GetTokenResponse
|
import com.atridad.mealient.datasource.models.GetTokenResponse
|
||||||
import com.atridad.mealient.datasource.models.GetUnitsResponse
|
import com.atridad.mealient.datasource.models.GetUnitsResponse
|
||||||
|
import com.atridad.mealient.datasource.models.GetUserFavoritesResponse
|
||||||
import com.atridad.mealient.datasource.models.GetUserInfoResponse
|
import com.atridad.mealient.datasource.models.GetUserInfoResponse
|
||||||
import com.atridad.mealient.datasource.models.ParseRecipeURLRequest
|
import com.atridad.mealient.datasource.models.ParseRecipeURLRequest
|
||||||
import com.atridad.mealient.datasource.models.UpdateRecipeRequest
|
import com.atridad.mealient.datasource.models.UpdateRecipeRequest
|
||||||
|
import com.atridad.mealient.datasource.models.UserProfileResponse
|
||||||
|
import com.atridad.mealient.datasource.models.UpdateUserProfileRequest
|
||||||
|
import com.atridad.mealient.datasource.models.UpdateUserResponse
|
||||||
|
import com.atridad.mealient.datasource.models.ChangePasswordRequest
|
||||||
|
import com.atridad.mealient.datasource.models.UpdateProfileImageRequest
|
||||||
import com.atridad.mealient.datasource.models.VersionResponse
|
import com.atridad.mealient.datasource.models.VersionResponse
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.call.body
|
import io.ktor.client.call.body
|
||||||
import io.ktor.client.request.HttpRequestBuilder
|
import io.ktor.client.request.HttpRequestBuilder
|
||||||
import io.ktor.client.request.delete
|
import io.ktor.client.request.delete
|
||||||
import io.ktor.client.request.forms.FormDataContent
|
import io.ktor.client.request.forms.FormDataContent
|
||||||
|
import io.ktor.client.request.forms.MultiPartFormDataContent
|
||||||
|
import io.ktor.client.request.forms.formData
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
import io.ktor.client.request.patch
|
import io.ktor.client.request.patch
|
||||||
import io.ktor.client.request.post
|
import io.ktor.client.request.post
|
||||||
import io.ktor.client.request.put
|
import io.ktor.client.request.put
|
||||||
import io.ktor.client.request.setBody
|
import io.ktor.client.request.setBody
|
||||||
import io.ktor.http.ContentType
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.Headers
|
||||||
|
import io.ktor.http.HttpHeaders
|
||||||
import io.ktor.http.URLBuilder
|
import io.ktor.http.URLBuilder
|
||||||
import io.ktor.http.contentType
|
import io.ktor.http.contentType
|
||||||
import io.ktor.http.parameters
|
import io.ktor.http.parameters
|
||||||
import io.ktor.http.path
|
import io.ktor.http.path
|
||||||
import io.ktor.http.takeFrom
|
import io.ktor.http.takeFrom
|
||||||
import kotlinx.serialization.json.JsonElement
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Provider
|
import javax.inject.Provider
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
internal class MealieServiceKtor @Inject constructor(
|
internal class MealieServiceKtor
|
||||||
private val httpClient: HttpClient,
|
@Inject
|
||||||
private val serverUrlProviderProvider: Provider<ServerUrlProvider>,
|
constructor(
|
||||||
|
private val httpClient: HttpClient,
|
||||||
|
private val serverUrlProviderProvider: Provider<ServerUrlProvider>,
|
||||||
) : MealieService {
|
) : MealieService {
|
||||||
|
|
||||||
private val serverUrlProvider: ServerUrlProvider
|
private val serverUrlProvider: ServerUrlProvider
|
||||||
@@ -52,111 +64,109 @@ internal class MealieServiceKtor @Inject constructor(
|
|||||||
append("password", password)
|
append("password", password)
|
||||||
}
|
}
|
||||||
|
|
||||||
return httpClient.post {
|
return httpClient
|
||||||
endpoint("/api/auth/token")
|
.post {
|
||||||
setBody(FormDataContent(formParameters))
|
endpoint("/api/auth/token")
|
||||||
}.body()
|
setBody(FormDataContent(formParameters))
|
||||||
|
}
|
||||||
|
.body()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun createRecipe(addRecipeRequest: CreateRecipeRequest): String {
|
override suspend fun createRecipe(addRecipeRequest: CreateRecipeRequest): String {
|
||||||
return httpClient.post {
|
return httpClient
|
||||||
endpoint("/api/recipes")
|
.post {
|
||||||
contentType(ContentType.Application.Json)
|
endpoint("/api/recipes")
|
||||||
setBody(addRecipeRequest)
|
contentType(ContentType.Application.Json)
|
||||||
}.body()
|
setBody(addRecipeRequest)
|
||||||
|
}
|
||||||
|
.body()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateRecipe(
|
override suspend fun updateRecipe(
|
||||||
addRecipeRequest: UpdateRecipeRequest,
|
addRecipeRequest: UpdateRecipeRequest,
|
||||||
slug: String,
|
slug: String,
|
||||||
): GetRecipeResponse {
|
): GetRecipeResponse {
|
||||||
return httpClient.patch {
|
return httpClient
|
||||||
endpoint("/api/recipes/$slug")
|
.patch {
|
||||||
contentType(ContentType.Application.Json)
|
endpoint("/api/recipes/$slug")
|
||||||
setBody(addRecipeRequest)
|
contentType(ContentType.Application.Json)
|
||||||
}.body()
|
setBody(addRecipeRequest)
|
||||||
|
}
|
||||||
|
.body()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getVersion(baseURL: String): VersionResponse {
|
override suspend fun getVersion(baseURL: String): VersionResponse {
|
||||||
return httpClient.get {
|
return httpClient.get { endpoint(baseURL, "/api/app/about") }.body()
|
||||||
endpoint(baseURL, "/api/app/about")
|
|
||||||
}.body()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getRecipeSummary(page: Int, perPage: Int): GetRecipesResponse {
|
override suspend fun getRecipeSummary(page: Int, perPage: Int): GetRecipesResponse {
|
||||||
return httpClient.get {
|
return httpClient
|
||||||
endpoint("/api/recipes") {
|
.get {
|
||||||
parameters.append("page", page.toString())
|
endpoint("/api/recipes") {
|
||||||
parameters.append("perPage", perPage.toString())
|
parameters.append("page", page.toString())
|
||||||
}
|
parameters.append("perPage", perPage.toString())
|
||||||
}.body()
|
}
|
||||||
|
}
|
||||||
|
.body()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getRecipe(slug: String): GetRecipeResponse {
|
override suspend fun getRecipe(slug: String): GetRecipeResponse {
|
||||||
return httpClient.get {
|
return httpClient.get { endpoint("/api/recipes/$slug") }.body()
|
||||||
endpoint("/api/recipes/$slug")
|
|
||||||
}.body()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun createRecipeFromURL(request: ParseRecipeURLRequest): String {
|
override suspend fun createRecipeFromURL(request: ParseRecipeURLRequest): String {
|
||||||
return httpClient.post {
|
return httpClient
|
||||||
endpoint("/api/recipes/create-url")
|
.post {
|
||||||
contentType(ContentType.Application.Json)
|
endpoint("/api/recipes/create-url")
|
||||||
setBody(request)
|
contentType(ContentType.Application.Json)
|
||||||
}.body()
|
setBody(request)
|
||||||
|
}
|
||||||
|
.body()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun createApiToken(request: CreateApiTokenRequest): CreateApiTokenResponse {
|
override suspend fun createApiToken(request: CreateApiTokenRequest): CreateApiTokenResponse {
|
||||||
return httpClient.post {
|
return httpClient
|
||||||
endpoint("/api/users/api-tokens")
|
.post {
|
||||||
contentType(ContentType.Application.Json)
|
endpoint("/api/users/api-tokens")
|
||||||
setBody(request)
|
contentType(ContentType.Application.Json)
|
||||||
}.body()
|
setBody(request)
|
||||||
|
}
|
||||||
|
.body()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getUserSelfInfo(): GetUserInfoResponse {
|
override suspend fun getUserSelfInfo(): GetUserInfoResponse {
|
||||||
return httpClient.get {
|
return httpClient.get { endpoint("/api/users/self") }.body()
|
||||||
endpoint("/api/users/self")
|
|
||||||
}.body()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun removeFavoriteRecipe(userId: String, recipeSlug: String) {
|
override suspend fun removeFavoriteRecipe(userId: String, recipeSlug: String) {
|
||||||
httpClient.delete {
|
httpClient.delete { endpoint("/api/users/$userId/favorites/$recipeSlug") }
|
||||||
endpoint("/api/users/$userId/favorites/$recipeSlug")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun addFavoriteRecipe(userId: String, recipeSlug: String) {
|
override suspend fun addFavoriteRecipe(userId: String, recipeSlug: String) {
|
||||||
httpClient.post {
|
httpClient.post { endpoint("/api/users/$userId/favorites/$recipeSlug") }
|
||||||
endpoint("/api/users/$userId/favorites/$recipeSlug")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteRecipe(slug: String) {
|
override suspend fun deleteRecipe(slug: String) {
|
||||||
httpClient.delete {
|
httpClient.delete { endpoint("/api/recipes/$slug") }
|
||||||
endpoint("/api/recipes/$slug")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getShoppingLists(page: Int, perPage: Int): GetShoppingListsResponse {
|
override suspend fun getShoppingLists(page: Int, perPage: Int): GetShoppingListsResponse {
|
||||||
return httpClient.get {
|
return httpClient
|
||||||
endpoint("/api/households/shopping/lists") {
|
.get {
|
||||||
parameters.append("page", page.toString())
|
endpoint("/api/households/shopping/lists") {
|
||||||
parameters.append("perPage", perPage.toString())
|
parameters.append("page", page.toString())
|
||||||
}
|
parameters.append("perPage", perPage.toString())
|
||||||
}.body()
|
}
|
||||||
|
}
|
||||||
|
.body()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getShoppingList(id: String): GetShoppingListResponse {
|
override suspend fun getShoppingList(id: String): GetShoppingListResponse {
|
||||||
return httpClient.get {
|
return httpClient.get { endpoint("/api/households/shopping/lists/$id") }.body()
|
||||||
endpoint("/api/households/shopping/lists/$id")
|
|
||||||
}.body()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getShoppingListItem(id: String): JsonElement {
|
override suspend fun getShoppingListItem(id: String): JsonElement {
|
||||||
return httpClient.get {
|
return httpClient.get { endpoint("/api/households/shopping/items/$id") }.body()
|
||||||
endpoint("/api/households/shopping/items/$id")
|
|
||||||
}.body()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateShoppingListItem(id: String, request: JsonElement) {
|
override suspend fun updateShoppingListItem(id: String, request: JsonElement) {
|
||||||
@@ -168,25 +178,19 @@ internal class MealieServiceKtor @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteShoppingListItem(id: String) {
|
override suspend fun deleteShoppingListItem(id: String) {
|
||||||
httpClient.delete {
|
httpClient.delete { endpoint("/api/households/shopping/items/$id") }
|
||||||
endpoint("/api/households/shopping/items/$id")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getFoods(perPage: Int): GetFoodsResponse {
|
override suspend fun getFoods(perPage: Int): GetFoodsResponse {
|
||||||
return httpClient.get {
|
return httpClient
|
||||||
endpoint("/api/foods") {
|
.get { endpoint("/api/foods") { parameters.append("perPage", perPage.toString()) } }
|
||||||
parameters.append("perPage", perPage.toString())
|
.body()
|
||||||
}
|
|
||||||
}.body()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getUnits(perPage: Int): GetUnitsResponse {
|
override suspend fun getUnits(perPage: Int): GetUnitsResponse {
|
||||||
return httpClient.get {
|
return httpClient
|
||||||
endpoint("/api/units") {
|
.get { endpoint("/api/units") { parameters.append("perPage", perPage.toString()) } }
|
||||||
parameters.append("perPage", perPage.toString())
|
.body()
|
||||||
}
|
|
||||||
}.body()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun createShoppingListItem(request: CreateShoppingListItemRequest) {
|
override suspend fun createShoppingListItem(request: CreateShoppingListItemRequest) {
|
||||||
@@ -206,9 +210,7 @@ internal class MealieServiceKtor @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteShoppingList(id: String) {
|
override suspend fun deleteShoppingList(id: String) {
|
||||||
httpClient.delete {
|
httpClient.delete { endpoint("/api/households/shopping/lists/$id") }
|
||||||
endpoint("/api/households/shopping/lists/$id")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateShoppingList(id: String, request: JsonElement) {
|
override suspend fun updateShoppingList(id: String, request: JsonElement) {
|
||||||
@@ -220,27 +222,66 @@ internal class MealieServiceKtor @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getShoppingListJson(id: String): JsonElement {
|
override suspend fun getShoppingListJson(id: String): JsonElement {
|
||||||
return httpClient.get {
|
return httpClient.get { endpoint("/api/households/shopping/lists/$id") }.body()
|
||||||
endpoint("/api/households/shopping/lists/$id")
|
}
|
||||||
}.body()
|
|
||||||
|
override suspend fun getUserFavoritesAlternative(userId: String): GetUserFavoritesResponse {
|
||||||
|
return httpClient.get { endpoint("/api/users/$userId/favorites") }.body()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun HttpRequestBuilder.endpoint(
|
private suspend fun HttpRequestBuilder.endpoint(
|
||||||
path: String,
|
path: String,
|
||||||
block: URLBuilder.() -> Unit = {},
|
block: URLBuilder.() -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val baseUrl = checkNotNull(serverUrlProvider.getUrl()) { "Server URL is not set" }
|
val baseUrl = checkNotNull(serverUrlProvider.getUrl()) { "Server URL is not set" }
|
||||||
endpoint(
|
endpoint(baseUrl = baseUrl, path = path, block = block)
|
||||||
baseUrl = baseUrl,
|
}
|
||||||
path = path,
|
|
||||||
block = block
|
// User Profile Management
|
||||||
)
|
override suspend fun getUserProfile(): UserProfileResponse {
|
||||||
|
return httpClient.get { endpoint("/api/users/self") }.body()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateUserProfile(userId: String, request: UpdateUserProfileRequest): UpdateUserResponse {
|
||||||
|
return httpClient.put {
|
||||||
|
endpoint("/api/users/$userId")
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(request)
|
||||||
|
}.body()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun changePassword(request: ChangePasswordRequest) {
|
||||||
|
httpClient.put {
|
||||||
|
endpoint("/api/users/password")
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateProfileImage(userId: String, request: UpdateProfileImageRequest) {
|
||||||
|
httpClient.post {
|
||||||
|
endpoint("/api/users/$userId/image")
|
||||||
|
setBody(
|
||||||
|
MultiPartFormDataContent(
|
||||||
|
formData {
|
||||||
|
append(
|
||||||
|
"profile_image",
|
||||||
|
request.imageBytes,
|
||||||
|
Headers.build {
|
||||||
|
append(HttpHeaders.ContentType, request.mimeType)
|
||||||
|
append(HttpHeaders.ContentDisposition, "filename=\"${request.fileName}\"")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun HttpRequestBuilder.endpoint(
|
private fun HttpRequestBuilder.endpoint(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
path: String,
|
path: String,
|
||||||
block: URLBuilder.() -> Unit = {},
|
block: URLBuilder.() -> Unit = {},
|
||||||
) {
|
) {
|
||||||
url {
|
url {
|
||||||
takeFrom(baseUrl)
|
takeFrom(baseUrl)
|
||||||
@@ -248,4 +289,6 @@ internal class MealieServiceKtor @Inject constructor(
|
|||||||
block()
|
block()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,12 +15,6 @@ internal class TokenChangeListenerKtor @Inject constructor(
|
|||||||
|
|
||||||
override fun onTokenChange() {
|
override fun onTokenChange() {
|
||||||
logger.v { "onTokenChange() called" }
|
logger.v { "onTokenChange() called" }
|
||||||
httpClient.plugin(Auth)
|
logger.d { "onTokenChange(): token change requested, will use new token on next request" }
|
||||||
.providers
|
|
||||||
.filterIsInstance<BearerAuthProvider>()
|
|
||||||
.forEach {
|
|
||||||
logger.d { "onTokenChange(): removing the token" }
|
|
||||||
it.clearToken()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,7 @@ data class GetRecipeResponse(
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class GetRecipeSettingsResponse(
|
data class GetRecipeSettingsResponse(
|
||||||
@SerialName("disableAmount") val disableAmount: Boolean,
|
@SerialName("disableAmount") val disableAmount: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -27,8 +27,8 @@ data class GetRecipeIngredientResponse(
|
|||||||
@SerialName("display") val display: String,
|
@SerialName("display") val display: String,
|
||||||
@SerialName("referenceId") val referenceId: String,
|
@SerialName("referenceId") val referenceId: String,
|
||||||
@SerialName("title") val title: String?,
|
@SerialName("title") val title: String?,
|
||||||
@SerialName("isFood") val isFood: Boolean,
|
@SerialName("isFood") val isFood: Boolean = false,
|
||||||
@SerialName("disableAmount") val disableAmount: Boolean,
|
@SerialName("disableAmount") val disableAmount: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.atridad.mealient.datasource.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GetUserFavoritesResponse(
|
||||||
|
@SerialName("ratings") val ratings: List<UserRatingResponse> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UserRatingResponse(
|
||||||
|
@SerialName("recipeId") val recipeId: String,
|
||||||
|
@SerialName("rating") val rating: Double? = null,
|
||||||
|
@SerialName("isFavorite") val isFavorite: Boolean,
|
||||||
|
@SerialName("userId") val userId: String,
|
||||||
|
@SerialName("id") val id: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package com.atridad.mealient.datasource.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UserProfileResponse(
|
||||||
|
@SerialName("id") val id: String,
|
||||||
|
@SerialName("username") val username: String?,
|
||||||
|
@SerialName("fullName") val fullName: String?,
|
||||||
|
@SerialName("email") val email: String,
|
||||||
|
@SerialName("authMethod") val authMethod: String? = null,
|
||||||
|
@SerialName("admin") val admin: Boolean,
|
||||||
|
@SerialName("group") val group: String? = null,
|
||||||
|
@SerialName("household") val household: String? = null,
|
||||||
|
@SerialName("advanced") val advanced: Boolean? = null,
|
||||||
|
@SerialName("canInvite") val canInvite: Boolean? = null,
|
||||||
|
@SerialName("canManage") val canManage: Boolean? = null,
|
||||||
|
@SerialName("canManageHousehold") val canManageHousehold: Boolean? = null,
|
||||||
|
@SerialName("canOrganize") val canOrganize: Boolean? = null,
|
||||||
|
@SerialName("groupId") val groupId: String? = null,
|
||||||
|
@SerialName("groupSlug") val groupSlug: String? = null,
|
||||||
|
@SerialName("householdId") val householdId: String? = null,
|
||||||
|
@SerialName("householdSlug") val householdSlug: String? = null,
|
||||||
|
@SerialName("tokens") val tokens: List<ApiToken>? = null,
|
||||||
|
@SerialName("cacheKey") val cacheKey: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ApiToken(
|
||||||
|
@SerialName("name") val name: String,
|
||||||
|
@SerialName("id") val id: Int,
|
||||||
|
@SerialName("createdAt") val createdAt: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UpdateUserResponse(
|
||||||
|
@SerialName("message") val message: String,
|
||||||
|
@SerialName("error") val error: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UpdateUserProfileRequest(
|
||||||
|
@SerialName("id") val id: String,
|
||||||
|
@SerialName("username") val username: String?,
|
||||||
|
@SerialName("fullName") val fullName: String?,
|
||||||
|
@SerialName("email") val email: String,
|
||||||
|
@SerialName("authMethod") val authMethod: String,
|
||||||
|
@SerialName("admin") val admin: Boolean,
|
||||||
|
@SerialName("group") val group: String?,
|
||||||
|
@SerialName("household") val household: String?,
|
||||||
|
@SerialName("advanced") val advanced: Boolean,
|
||||||
|
@SerialName("canInvite") val canInvite: Boolean,
|
||||||
|
@SerialName("canManage") val canManage: Boolean,
|
||||||
|
@SerialName("canManageHousehold") val canManageHousehold: Boolean,
|
||||||
|
@SerialName("canOrganize") val canOrganize: Boolean,
|
||||||
|
@SerialName("groupId") val groupId: String?,
|
||||||
|
@SerialName("groupSlug") val groupSlug: String?,
|
||||||
|
@SerialName("householdId") val householdId: String?,
|
||||||
|
@SerialName("householdSlug") val householdSlug: String?,
|
||||||
|
@SerialName("tokens") val tokens: List<ApiToken>?,
|
||||||
|
@SerialName("cacheKey") val cacheKey: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper to create an update request from existing profile, preserving all permissions
|
||||||
|
fun UserProfileResponse.toUpdateRequest(
|
||||||
|
newFullName: String? = null,
|
||||||
|
newEmail: String? = null,
|
||||||
|
newUsername: String? = null
|
||||||
|
): UpdateUserProfileRequest {
|
||||||
|
return UpdateUserProfileRequest(
|
||||||
|
id = this.id,
|
||||||
|
username = newUsername ?: this.username,
|
||||||
|
fullName = newFullName ?: this.fullName,
|
||||||
|
email = newEmail ?: this.email,
|
||||||
|
authMethod = this.authMethod ?: "Mealie",
|
||||||
|
admin = this.admin, // Preserve existing admin status
|
||||||
|
group = this.group,
|
||||||
|
household = this.household,
|
||||||
|
advanced = this.advanced ?: true,
|
||||||
|
canInvite = this.canInvite ?: true,
|
||||||
|
canManage = this.canManage ?: true,
|
||||||
|
canManageHousehold = this.canManageHousehold ?: true,
|
||||||
|
canOrganize = this.canOrganize ?: true,
|
||||||
|
groupId = this.groupId,
|
||||||
|
groupSlug = this.groupSlug,
|
||||||
|
householdId = this.householdId,
|
||||||
|
householdSlug = this.householdSlug,
|
||||||
|
tokens = this.tokens,
|
||||||
|
cacheKey = this.cacheKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChangePasswordRequest(
|
||||||
|
@SerialName("currentPassword") val currentPassword: String = "",
|
||||||
|
@SerialName("newPassword") val newPassword: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UpdateProfileImageRequest(
|
||||||
|
val imageBytes: ByteArray,
|
||||||
|
val fileName: String,
|
||||||
|
val mimeType: String = "image/jpeg"
|
||||||
|
) {
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as UpdateProfileImageRequest
|
||||||
|
|
||||||
|
if (!imageBytes.contentEquals(other.imageBytes)) return false
|
||||||
|
if (fileName != other.fileName) return false
|
||||||
|
if (mimeType != other.mimeType) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = imageBytes.contentHashCode()
|
||||||
|
result = 31 * result + fileName.hashCode()
|
||||||
|
result = 31 * result + mimeType.hashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
syntax = "proto3";
|
syntax = "proto3";
|
||||||
|
|
||||||
option java_package = "gq.kirmanak.mealient.datastore.recipe";
|
option java_package = "com.atridad.mealient.datastore.recipe";
|
||||||
option java_multiple_files = true;
|
option java_multiple_files = true;
|
||||||
|
|
||||||
message AddRecipeInput {
|
message AddRecipeInput {
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
Share recipes from a web browser to Mealient to save them in Mealie.
|
|
||||||
Fixed a case when "No recipes" text was shown on top of recipes.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Ingredient amounts and ingredient titles are now supported.
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
Mealient will generate an API token and use it instead of the e-mail and password.
|
|
||||||
The app will allow deleting recipes or marking them as favorites.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Mealient will fallback to HTTP if HTTPS is not available.
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
Added support for nightly versions of Mealie.
|
|
||||||
Added some UI/UX improvements.
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
Added support for per-app language settings.
|
|
||||||
Added translation to Spanish language.
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
Added an option to display the shopping lists.
|
|
||||||
Added an option to accept self-signed SSL certificates.
|
|
||||||
Added machine translation to Dutch, German, French, and Portuguese.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
The app will keep screen on while viewing a recipe
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Ingredients that are linked to a specific recipe step are shown under that step.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Display notes under each recipe ingredient.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Removed crash reporting.
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
Fix authentication issues with some Mealie instances.
|
|
||||||
Allow sending logs to the developer.
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
Now authentication screen is shown automatically when authentication fails.
|
|
||||||
The recipe ingredient notes are no longer duplicated.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Now you can add new shopping lists as well as rename and remove existing ones.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Fixed incompatibility with Mealie v1.11.0.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Mealient enables you to easily access the recipes stored in your Mealie instance using your phone.
|
|
||||||
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 64 KiB |
@@ -1 +0,0 @@
|
|||||||
Unofficial client for the self-hosted recipe manager Mealie.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Mealient
|
|
||||||
@@ -8,7 +8,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.atridad.mealient.shopping_lists"
|
namespace = "com.atridad.mealient.shopping_list"
|
||||||
}
|
}
|
||||||
|
|
||||||
ksp {
|
ksp {
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||||
@@ -17,6 +19,8 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.NoMeals
|
import androidx.compose.material.icons.filled.NoMeals
|
||||||
import androidx.compose.material.icons.filled.Restaurant
|
import androidx.compose.material.icons.filled.Restaurant
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
@@ -60,7 +64,7 @@ import com.atridad.mealient.shopping_lists.ui.composables.getErrorMessage
|
|||||||
import com.atridad.mealient.shopping_lists.util.ItemLabelGroup
|
import com.atridad.mealient.shopping_lists.util.ItemLabelGroup
|
||||||
import com.atridad.mealient.ui.AppTheme
|
import com.atridad.mealient.ui.AppTheme
|
||||||
import com.atridad.mealient.ui.Dimens
|
import com.atridad.mealient.ui.Dimens
|
||||||
import com.atridad.mealient.ui.components.BaseScreen
|
|
||||||
import com.atridad.mealient.ui.components.LazyColumnWithLoadingState
|
import com.atridad.mealient.ui.components.LazyColumnWithLoadingState
|
||||||
import com.atridad.mealient.ui.preview.ColorSchemePreview
|
import com.atridad.mealient.ui.preview.ColorSchemePreview
|
||||||
import com.atridad.mealient.ui.util.LoadingState
|
import com.atridad.mealient.ui.util.LoadingState
|
||||||
@@ -68,6 +72,7 @@ import com.atridad.mealient.ui.util.data
|
|||||||
import com.atridad.mealient.ui.util.error
|
import com.atridad.mealient.ui.util.error
|
||||||
import com.atridad.mealient.ui.util.map
|
import com.atridad.mealient.ui.util.map
|
||||||
import java.text.DecimalFormat
|
import java.text.DecimalFormat
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
data class ShoppingListNavArgs(
|
data class ShoppingListNavArgs(
|
||||||
val shoppingListId: String,
|
val shoppingListId: String,
|
||||||
@@ -82,25 +87,24 @@ internal fun ShoppingListScreen(
|
|||||||
) {
|
) {
|
||||||
val loadingState by shoppingListViewModel.loadingState.collectAsState()
|
val loadingState by shoppingListViewModel.loadingState.collectAsState()
|
||||||
|
|
||||||
BaseScreen { modifier ->
|
ShoppingListScreen(
|
||||||
ShoppingListScreen(
|
modifier = Modifier.fillMaxSize(),
|
||||||
modifier = modifier,
|
loadingState = loadingState,
|
||||||
loadingState = loadingState,
|
errorToShowInSnackbar = shoppingListViewModel.errorToShowInSnackbar,
|
||||||
errorToShowInSnackbar = shoppingListViewModel.errorToShowInSnackbar,
|
onSnackbarShown = shoppingListViewModel::onSnackbarShown,
|
||||||
onSnackbarShown = shoppingListViewModel::onSnackbarShown,
|
onRefreshRequest = shoppingListViewModel::refreshShoppingList,
|
||||||
onRefreshRequest = shoppingListViewModel::refreshShoppingList,
|
onAddItemClicked = shoppingListViewModel::onAddItemClicked,
|
||||||
onAddItemClicked = shoppingListViewModel::onAddItemClicked,
|
onEditCancel = shoppingListViewModel::onEditCancel,
|
||||||
onEditCancel = shoppingListViewModel::onEditCancel,
|
onEditConfirm = shoppingListViewModel::onEditConfirm,
|
||||||
onEditConfirm = shoppingListViewModel::onEditConfirm,
|
onItemCheckedChange = shoppingListViewModel::onItemCheckedChange,
|
||||||
onItemCheckedChange = shoppingListViewModel::onItemCheckedChange,
|
onDeleteItem = shoppingListViewModel::deleteShoppingListItem,
|
||||||
onDeleteItem = shoppingListViewModel::deleteShoppingListItem,
|
onEditStart = shoppingListViewModel::onEditStart,
|
||||||
onEditStart = shoppingListViewModel::onEditStart,
|
onAddCancel = shoppingListViewModel::onAddCancel,
|
||||||
onAddCancel = shoppingListViewModel::onAddCancel,
|
onAddConfirm = shoppingListViewModel::onAddConfirm,
|
||||||
onAddConfirm = shoppingListViewModel::onAddConfirm,
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ShoppingListScreen(
|
private fun ShoppingListScreen(
|
||||||
loadingState: LoadingState<ShoppingListScreenState>,
|
loadingState: LoadingState<ShoppingListScreenState>,
|
||||||
@@ -117,6 +121,27 @@ private fun ShoppingListScreen(
|
|||||||
onAddConfirm: (ShoppingListItemState.NewItem) -> Unit,
|
onAddConfirm: (ShoppingListItemState.NewItem) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val listName = loadingState.data?.name ?: "Shopping List"
|
||||||
|
|
||||||
|
androidx.compose.material3.Scaffold(
|
||||||
|
topBar = {
|
||||||
|
androidx.compose.material3.TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = listName,
|
||||||
|
style = androidx.compose.material3.MaterialTheme.typography.headlineLarge,
|
||||||
|
color = androidx.compose.material3.MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = androidx.compose.material3.MaterialTheme.colorScheme.surface,
|
||||||
|
titleContentColor = androidx.compose.material3.MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
val defaultEmptyListError = stringResource(
|
val defaultEmptyListError = stringResource(
|
||||||
R.string.shopping_list_screen_empty_list,
|
R.string.shopping_list_screen_empty_list,
|
||||||
loadingState.data?.name.orEmpty()
|
loadingState.data?.name.orEmpty()
|
||||||
@@ -134,7 +159,7 @@ private fun ShoppingListScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LazyColumnWithLoadingState(
|
LazyColumnWithLoadingState(
|
||||||
modifier = modifier,
|
modifier = modifier.padding(paddingValues),
|
||||||
loadingState = loadingState.map { it.items },
|
loadingState = loadingState.map { it.items },
|
||||||
emptyListError = loadingState.error?.let { getErrorMessage(it) } ?: defaultEmptyListError,
|
emptyListError = loadingState.error?.let { getErrorMessage(it) } ?: defaultEmptyListError,
|
||||||
retryButtonText = stringResource(id = R.string.shopping_lists_screen_empty_button_refresh),
|
retryButtonText = stringResource(id = R.string.shopping_lists_screen_empty_button_refresh),
|
||||||
@@ -149,7 +174,6 @@ private fun ShoppingListScreen(
|
|||||||
onSnackbarShown = onSnackbarShown,
|
onSnackbarShown = onSnackbarShown,
|
||||||
onRefresh = onRefreshRequest,
|
onRefresh = onRefreshRequest,
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
// Only show the button if the editor is not active to avoid overlapping
|
|
||||||
if (!itemBeingEdited) {
|
if (!itemBeingEdited) {
|
||||||
FloatingActionButton(onClick = onAddItemClicked) {
|
FloatingActionButton(onClick = onAddItemClicked) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -159,56 +183,70 @@ private fun ShoppingListScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
lazyListState = lazyListState
|
lazyListState = lazyListState,
|
||||||
) { sortedItems ->
|
lazyColumnContent = { sortedItems ->
|
||||||
|
lastAddedItemIndex = sortedItems.indexOfLast { it is ShoppingListItemState.NewItem }
|
||||||
|
val firstCheckedItemIndex = sortedItems.indexOfFirst { it.checked }
|
||||||
|
|
||||||
lastAddedItemIndex = sortedItems.indexOfLast { it is ShoppingListItemState.NewItem }
|
if (sortedItems.isNotEmpty()) {
|
||||||
val firstCheckedItemIndex = sortedItems.indexOfFirst { it.checked }
|
item(key = "hint") {
|
||||||
|
Text(
|
||||||
itemsIndexed(sortedItems, { _, item -> item.id}) { index, itemState ->
|
text = "💡 Swipe left to delete, swipe right to edit",
|
||||||
when (itemState) {
|
style = MaterialTheme.typography.bodySmall,
|
||||||
is ShoppingListItemState.ItemLabel -> {
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
|
||||||
ShoppingListSectionHeader(state = itemState)
|
modifier = Modifier.padding(
|
||||||
}
|
horizontal = Dimens.Small,
|
||||||
is ShoppingListItemState.ExistingItem -> {
|
vertical = Dimens.Small
|
||||||
if (itemState.isEditing) {
|
|
||||||
val state = remember {
|
|
||||||
ShoppingListItemEditorState(
|
|
||||||
state = itemState,
|
|
||||||
foods = loadingState.data?.foods.orEmpty(),
|
|
||||||
units = loadingState.data?.units.orEmpty(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ShoppingListItemEditor(
|
|
||||||
state = state,
|
|
||||||
onEditCancelled = { onEditCancel(itemState) },
|
|
||||||
onEditConfirmed = { onEditConfirm(itemState, state) },
|
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
ShoppingListItem(
|
|
||||||
itemState = itemState,
|
|
||||||
showDivider = firstCheckedItemIndex == index,
|
|
||||||
modifier = Modifier.background(MaterialTheme.colorScheme.surface),
|
|
||||||
onCheckedChange = { onItemCheckedChange(itemState, it) },
|
|
||||||
onDismissed = { onDeleteItem(itemState) },
|
|
||||||
onEditStart = {
|
|
||||||
// Only allow one item to be edited at a time
|
|
||||||
if (!itemBeingEdited) {
|
|
||||||
onEditStart(itemState)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is ShoppingListItemState.NewItem -> {
|
|
||||||
ShoppingListItemEditor(
|
|
||||||
state = itemState.item,
|
|
||||||
onEditCancelled = { onAddCancel(itemState) },
|
|
||||||
onEditConfirmed = { onAddConfirm(itemState) },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
itemsIndexed(sortedItems, { _, item -> item.id}) { index, itemState ->
|
||||||
|
when (itemState) {
|
||||||
|
is ShoppingListItemState.ItemLabel -> {
|
||||||
|
ShoppingListSectionHeader(state = itemState)
|
||||||
|
}
|
||||||
|
is ShoppingListItemState.ExistingItem -> {
|
||||||
|
if (itemState.isEditing) {
|
||||||
|
val state = remember {
|
||||||
|
ShoppingListItemEditorState(
|
||||||
|
state = itemState,
|
||||||
|
foods = loadingState.data?.foods.orEmpty(),
|
||||||
|
units = loadingState.data?.units.orEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ShoppingListItemEditor(
|
||||||
|
state = state,
|
||||||
|
onEditCancelled = { onEditCancel(itemState) },
|
||||||
|
onEditConfirmed = { onEditConfirm(itemState, state) },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ShoppingListItem(
|
||||||
|
itemState = itemState,
|
||||||
|
showDivider = firstCheckedItemIndex == index,
|
||||||
|
modifier = Modifier.background(MaterialTheme.colorScheme.surface),
|
||||||
|
onCheckedChange = { onItemCheckedChange(itemState, it) },
|
||||||
|
onDismissed = { onDeleteItem(itemState) },
|
||||||
|
onEditStart = {
|
||||||
|
if (!itemBeingEdited) {
|
||||||
|
onEditStart(itemState)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ShoppingListItemState.NewItem -> {
|
||||||
|
ShoppingListItemEditor(
|
||||||
|
state = itemState.item,
|
||||||
|
onEditCancelled = { onAddCancel(itemState) },
|
||||||
|
onEditConfirmed = { onAddConfirm(itemState) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,6 +645,7 @@ fun ShoppingListItem(
|
|||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.Start,
|
horizontalArrangement = Arrangement.Start,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Checkbox(
|
Checkbox(
|
||||||
checked = itemState.item.checked,
|
checked = itemState.item.checked,
|
||||||
@@ -653,7 +692,6 @@ fun ShoppingListItem(
|
|||||||
if (!isFood) {
|
if (!isFood) {
|
||||||
appendBold(shoppingListItem.note)
|
appendBold(shoppingListItem.note)
|
||||||
} else {
|
} else {
|
||||||
// Add plural unit and food name if available
|
|
||||||
shoppingListItem.unit?.let { unit ->
|
shoppingListItem.unit?.let { unit ->
|
||||||
appendWithPlural(unit.name, unit.pluralName,
|
appendWithPlural(unit.name, unit.pluralName,
|
||||||
shoppingListItem.quantity, ::appendWithSpace)
|
shoppingListItem.quantity, ::appendWithSpace)
|
||||||
@@ -665,22 +703,29 @@ fun ShoppingListItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// only show note in secondary text if it's a food item due
|
|
||||||
// to the note already being displayed in the primary text otherwise
|
|
||||||
val secondaryText = shoppingListItem.takeIf { isFood }?.note.orEmpty()
|
val secondaryText = shoppingListItem.takeIf { isFood }?.note.orEmpty()
|
||||||
|
|
||||||
Column {
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(end = Dimens.Small)
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = primaryText,
|
text = primaryText,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
if (secondaryText.isNotBlank()) {
|
if (secondaryText.isNotBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = secondaryText,
|
text = secondaryText,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,16 +3,20 @@ package com.atridad.mealient.shopping_lists.ui.list
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.ShoppingCart
|
import androidx.compose.material.icons.filled.ShoppingCart
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -26,21 +30,14 @@ import com.ramcosta.composedestinations.annotation.Destination
|
|||||||
import com.ramcosta.composedestinations.navigation.navigate
|
import com.ramcosta.composedestinations.navigation.navigate
|
||||||
import com.atridad.mealient.shopping_list.R
|
import com.atridad.mealient.shopping_list.R
|
||||||
import com.atridad.mealient.shopping_lists.ui.composables.EditableItemBox
|
import com.atridad.mealient.shopping_lists.ui.composables.EditableItemBox
|
||||||
import com.atridad.mealient.shopping_lists.ui.composables.getErrorMessage
|
|
||||||
import com.atridad.mealient.shopping_lists.ui.destinations.ShoppingListScreenDestination
|
import com.atridad.mealient.shopping_lists.ui.destinations.ShoppingListScreenDestination
|
||||||
import com.atridad.mealient.ui.AppTheme
|
|
||||||
import com.atridad.mealient.ui.Dimens
|
import com.atridad.mealient.ui.Dimens
|
||||||
import com.atridad.mealient.ui.components.BaseScreenState
|
|
||||||
import com.atridad.mealient.ui.components.BaseScreenWithNavigation
|
|
||||||
import com.atridad.mealient.ui.components.LazyColumnWithLoadingState
|
|
||||||
import com.atridad.mealient.ui.preview.ColorSchemePreview
|
|
||||||
import com.atridad.mealient.ui.util.error
|
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Destination
|
@Destination
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ShoppingListsScreen(
|
internal fun ShoppingListsScreen(
|
||||||
navController: NavController,
|
navController: NavController,
|
||||||
baseScreenState: BaseScreenState,
|
|
||||||
shoppingListsViewModel: ShoppingListsViewModel = hiltViewModel(),
|
shoppingListsViewModel: ShoppingListsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val screenState by shoppingListsViewModel.shoppingListsState.collectAsState()
|
val screenState by shoppingListsViewModel.shoppingListsState.collectAsState()
|
||||||
@@ -50,47 +47,76 @@ internal fun ShoppingListsScreen(
|
|||||||
onEvent = shoppingListsViewModel::onEvent
|
onEvent = shoppingListsViewModel::onEvent
|
||||||
)
|
)
|
||||||
|
|
||||||
BaseScreenWithNavigation(
|
Scaffold(
|
||||||
baseScreenState = baseScreenState,
|
topBar = {
|
||||||
) { modifier ->
|
androidx.compose.material3.TopAppBar(
|
||||||
LazyColumnWithLoadingState(
|
title = {
|
||||||
modifier = modifier,
|
Text(
|
||||||
loadingState = screenState.loadingState,
|
text = "Shopping Lists",
|
||||||
emptyListError = screenState.loadingState.error?.let { getErrorMessage(it) }
|
style = androidx.compose.material3.MaterialTheme.typography.headlineLarge,
|
||||||
?: stringResource(R.string.shopping_lists_screen_empty),
|
color = androidx.compose.material3.MaterialTheme.colorScheme.onSurface
|
||||||
retryButtonText = stringResource(id = R.string.shopping_lists_screen_empty_button_refresh),
|
|
||||||
snackbarText = screenState.errorToShow?.let { getErrorMessage(error = it) },
|
|
||||||
onSnackbarShown = { shoppingListsViewModel.onEvent(ShoppingListsEvent.SnackbarShown) },
|
|
||||||
onRefresh = { shoppingListsViewModel.onEvent(ShoppingListsEvent.RefreshRequested) },
|
|
||||||
floatingActionButton = {
|
|
||||||
FloatingActionButton(
|
|
||||||
onClick = { shoppingListsViewModel.onEvent(ShoppingListsEvent.AddShoppingList) }
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Add,
|
|
||||||
contentDescription = stringResource(id = R.string.shopping_lists_screen_add_icon_content_description),
|
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
},
|
colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors(
|
||||||
) { items ->
|
containerColor = androidx.compose.material3.MaterialTheme.colorScheme.surface,
|
||||||
items(
|
titleContentColor = androidx.compose.material3.MaterialTheme.colorScheme.onSurface
|
||||||
items = items,
|
|
||||||
key = { it.id },
|
|
||||||
contentType = { "Existing list" }
|
|
||||||
) { displayList ->
|
|
||||||
ShoppingListCard(
|
|
||||||
listName = displayList.name,
|
|
||||||
onClick = {
|
|
||||||
val shoppingListId = displayList.id
|
|
||||||
navController.navigate(ShoppingListScreenDestination(shoppingListId))
|
|
||||||
},
|
|
||||||
onDelete = {
|
|
||||||
shoppingListsViewModel.onEvent(ShoppingListsEvent.RemoveList(displayList))
|
|
||||||
},
|
|
||||||
onEdit = {
|
|
||||||
shoppingListsViewModel.onEvent(ShoppingListsEvent.EditList(displayList))
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = { shoppingListsViewModel.onEvent(ShoppingListsEvent.AddShoppingList) }
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
contentDescription = stringResource(id = R.string.shopping_lists_screen_add_icon_content_description),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
// Simple loading state
|
||||||
|
if (screenState.loadingState is com.atridad.mealient.ui.util.LoadingStateNoData.InitialLoad) {
|
||||||
|
Text(
|
||||||
|
text = "Loading...",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.padding(Dimens.Large)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Show shopping lists or empty state
|
||||||
|
val shoppingLists = (screenState.loadingState as? com.atridad.mealient.ui.util.LoadingStateWithData.Success)?.data ?: emptyList()
|
||||||
|
|
||||||
|
if (shoppingLists.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.shopping_lists_screen_empty),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.padding(Dimens.Large)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
) {
|
||||||
|
items(shoppingLists) { displayList ->
|
||||||
|
ShoppingListCard(
|
||||||
|
listName = displayList.name,
|
||||||
|
onClick = {
|
||||||
|
val shoppingListId = displayList.id
|
||||||
|
navController.navigate(ShoppingListScreenDestination(shoppingListId))
|
||||||
|
},
|
||||||
|
onDelete = {
|
||||||
|
shoppingListsViewModel.onEvent(ShoppingListsEvent.RemoveList(displayList))
|
||||||
|
},
|
||||||
|
onEdit = {
|
||||||
|
shoppingListsViewModel.onEvent(ShoppingListsEvent.EditList(displayList))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,9 +135,7 @@ private fun ShoppingListsScreenDialog(
|
|||||||
listName = dialog.listName,
|
listName = dialog.listName,
|
||||||
oldName = dialog.oldListName
|
oldName = dialog.oldListName
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is ShoppingListsDialog.NewListItem -> {
|
is ShoppingListsDialog.NewListItem -> {
|
||||||
ShoppingListNameDialog(
|
ShoppingListNameDialog(
|
||||||
onEvent = onEvent,
|
onEvent = onEvent,
|
||||||
@@ -119,8 +143,6 @@ private fun ShoppingListsScreenDialog(
|
|||||||
listName = dialog.listName
|
listName = dialog.listName
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
is ShoppingListsDialog.RemoveListItem -> {
|
is ShoppingListsDialog.RemoveListItem -> {
|
||||||
DeleteListConfirmDialog(
|
DeleteListConfirmDialog(
|
||||||
onEvent = onEvent,
|
onEvent = onEvent,
|
||||||
@@ -128,7 +150,6 @@ private fun ShoppingListsScreenDialog(
|
|||||||
listName = dialog.listName
|
listName = dialog.listName
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
is ShoppingListsDialog.None -> {
|
is ShoppingListsDialog.None -> {
|
||||||
Unit
|
Unit
|
||||||
}
|
}
|
||||||
@@ -179,8 +200,6 @@ private fun ShoppingListCard(
|
|||||||
imageVector = Icons.Default.ShoppingCart,
|
imageVector = Icons.Default.ShoppingCart,
|
||||||
contentDescription = stringResource(id = R.string.shopping_lists_screen_cart_icon),
|
contentDescription = stringResource(id = R.string.shopping_lists_screen_cart_icon),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = listName,
|
text = listName,
|
||||||
)
|
)
|
||||||
@@ -190,29 +209,3 @@ private fun ShoppingListCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
@ColorSchemePreview
|
|
||||||
private fun PreviewShoppingListCard() {
|
|
||||||
AppTheme {
|
|
||||||
ShoppingListCard(
|
|
||||||
listName = "Weekend shopping",
|
|
||||||
onClick = {},
|
|
||||||
onDelete = {},
|
|
||||||
onEdit = {}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
@ColorSchemePreview
|
|
||||||
private fun PreviewEditingShoppingListCard() {
|
|
||||||
AppTheme {
|
|
||||||
ShoppingListCard(
|
|
||||||
listName = "Weekend shopping",
|
|
||||||
onClick = {},
|
|
||||||
onDelete = {},
|
|
||||||
onEdit = {}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
1
features/user_managment/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
42
features/user_managment/build.gradle.kts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
@file:Suppress("UnstableApiUsage")
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("com.atridad.mealient.library")
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
|
id("com.atridad.mealient.compose")
|
||||||
|
id("dagger.hilt.android.plugin")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.mealient.user_management"
|
||||||
|
}
|
||||||
|
|
||||||
|
ksp {
|
||||||
|
arg("compose-destinations.generateNavGraphs", "false")
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":architecture"))
|
||||||
|
implementation(project(":logging"))
|
||||||
|
implementation(project(":datasource"))
|
||||||
|
implementation(project(":ui"))
|
||||||
|
implementation(project(":model_mapper"))
|
||||||
|
implementation(libs.android.material.material)
|
||||||
|
implementation(libs.androidx.compose.material)
|
||||||
|
implementation(libs.androidx.compose.materialIconsExtended)
|
||||||
|
implementation(libs.google.dagger.hiltAndroid)
|
||||||
|
implementation(libs.androidx.hilt.navigationCompose)
|
||||||
|
implementation(libs.jetbrains.kotlinx.coroutinesAndroid)
|
||||||
|
implementation(libs.coil.compose)
|
||||||
|
|
||||||
|
ksp(libs.google.dagger.hiltCompiler)
|
||||||
|
|
||||||
|
kspTest(libs.google.dagger.hiltAndroidCompiler)
|
||||||
|
|
||||||
|
testImplementation(project(":testing"))
|
||||||
|
testImplementation(libs.google.dagger.hiltAndroidTesting)
|
||||||
|
testImplementation(libs.jetbrains.kotlinx.coroutinesTest)
|
||||||
|
testImplementation(libs.androidx.test.junit)
|
||||||
|
testImplementation(libs.google.truth)
|
||||||
|
testImplementation(libs.io.mockk)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.mealient.user_management.data
|
||||||
|
|
||||||
|
import com.atridad.mealient.datasource.models.UserProfileResponse
|
||||||
|
import com.atridad.mealient.datasource.models.UpdateUserProfileRequest
|
||||||
|
import com.atridad.mealient.datasource.models.ChangePasswordRequest
|
||||||
|
import com.atridad.mealient.datasource.models.UpdateProfileImageRequest
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
interface UserProfileRepository {
|
||||||
|
suspend fun getUserProfile(): UserProfileResponse
|
||||||
|
suspend fun updateUserProfile(request: UpdateUserProfileRequest): UserProfileResponse
|
||||||
|
suspend fun changePassword(request: ChangePasswordRequest)
|
||||||
|
suspend fun updateProfileImage(request: UpdateProfileImageRequest)
|
||||||
|
val currentUser: Flow<UserProfileResponse?>
|
||||||
|
fun getCurrentUserValue(): UserProfileResponse?
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.mealient.user_management.data
|
||||||
|
|
||||||
|
import com.atridad.mealient.datasource.MealieDataSource
|
||||||
|
import com.atridad.mealient.datasource.models.UserProfileResponse
|
||||||
|
import com.atridad.mealient.datasource.models.UpdateUserProfileRequest
|
||||||
|
import com.atridad.mealient.datasource.models.ChangePasswordRequest
|
||||||
|
import com.atridad.mealient.datasource.models.UpdateProfileImageRequest
|
||||||
|
import com.atridad.mealient.logging.Logger
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class UserProfileRepositoryImpl @Inject constructor(
|
||||||
|
private val dataSource: MealieDataSource,
|
||||||
|
private val logger: Logger,
|
||||||
|
) : UserProfileRepository {
|
||||||
|
|
||||||
|
private val _currentUser = MutableStateFlow<UserProfileResponse?>(null)
|
||||||
|
override val currentUser: Flow<UserProfileResponse?> = _currentUser.asStateFlow()
|
||||||
|
|
||||||
|
override suspend fun getUserProfile(): UserProfileResponse {
|
||||||
|
logger.v { "getUserProfile() called" }
|
||||||
|
val profile = dataSource.getUserProfile()
|
||||||
|
_currentUser.value = profile
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateUserProfile(request: UpdateUserProfileRequest): UserProfileResponse {
|
||||||
|
logger.v { "updateUserProfile() called" }
|
||||||
|
val currentUserId = checkNotNull(_currentUser.value?.id) { "User profile not loaded" }
|
||||||
|
// Update the profile (returns success message)
|
||||||
|
dataSource.updateUserProfile(currentUserId, request)
|
||||||
|
// Fetch the updated profile
|
||||||
|
val updatedProfile = getUserProfile()
|
||||||
|
return updatedProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun changePassword(request: ChangePasswordRequest) {
|
||||||
|
logger.v { "changePassword() called" }
|
||||||
|
dataSource.changePassword(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateProfileImage(request: UpdateProfileImageRequest) {
|
||||||
|
logger.v { "updateProfileImage() called" }
|
||||||
|
val currentUserId = checkNotNull(_currentUser.value?.id) { "User profile not loaded" }
|
||||||
|
dataSource.updateProfileImage(currentUserId, request)
|
||||||
|
// Refresh profile to get updated image URL
|
||||||
|
getUserProfile()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCurrentUserValue(): UserProfileResponse? {
|
||||||
|
return _currentUser.value
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.mealient.user_management.di
|
||||||
|
|
||||||
|
import com.mealient.user_management.data.UserProfileRepository
|
||||||
|
import com.mealient.user_management.data.UserProfileRepositoryImpl
|
||||||
|
import dagger.Binds
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
internal interface UserManagementModule {
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
fun bindUserProfileRepository(
|
||||||
|
userProfileRepositoryImpl: UserProfileRepositoryImpl
|
||||||
|
): UserProfileRepository
|
||||||
|
}
|
||||||
@@ -0,0 +1,549 @@
|
|||||||
|
package com.mealient.user_management.ui.profile
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusDirection
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import com.ramcosta.composedestinations.annotation.Destination
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
|
||||||
|
@Destination
|
||||||
|
@Composable
|
||||||
|
fun UserProfileScreen(
|
||||||
|
viewModel: UserProfileViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.screenState.collectAsState()
|
||||||
|
|
||||||
|
UserProfileContent(
|
||||||
|
state = state,
|
||||||
|
onEvent = viewModel::onEvent,
|
||||||
|
onSelectImage = { /* TODO: Implement image selection */ }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun UserProfileContent(
|
||||||
|
state: UserProfileScreenState,
|
||||||
|
onEvent: (ProfileScreenEvent) -> Unit,
|
||||||
|
onSelectImage: () -> Unit,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Profile") },
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
actionIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
if (!state.isChangingPassword) {
|
||||||
|
if (state.isEditing) {
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
text = { Text("Save") },
|
||||||
|
icon = { Icon(Icons.Default.Check, contentDescription = null) },
|
||||||
|
onClick = { onEvent(ProfileScreenEvent.SaveProfile) },
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
expanded = true,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
FloatingActionButton(onClick = { onEvent(ProfileScreenEvent.StartEditing) }) {
|
||||||
|
Icon(Icons.Default.Edit, contentDescription = "Edit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
// Content
|
||||||
|
if (state.isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
// Profile Image Section
|
||||||
|
ProfileImageSection(
|
||||||
|
profileImageUrl = state.user?.profileImageUrl,
|
||||||
|
isEditing = state.isEditing,
|
||||||
|
onSelectImage = onSelectImage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
// User Info Section
|
||||||
|
UserInfoSection(
|
||||||
|
state = state,
|
||||||
|
onEvent = onEvent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.isChangingPassword) {
|
||||||
|
item {
|
||||||
|
// Password Change Section
|
||||||
|
PasswordChangeSection(
|
||||||
|
onStartChangingPassword = { onEvent(ProfileScreenEvent.StartChangingPassword) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
item {
|
||||||
|
// Password Change Form
|
||||||
|
PasswordChangeForm(
|
||||||
|
state = state,
|
||||||
|
onEvent = onEvent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
// Additional Info Section
|
||||||
|
AdditionalInfoSection(user = state.user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error/Success Messages
|
||||||
|
state.errorMessage?.let { message ->
|
||||||
|
LaunchedEffect(message) {
|
||||||
|
// You could show a Snackbar here
|
||||||
|
kotlinx.coroutines.delay(3000)
|
||||||
|
onEvent(ProfileScreenEvent.ClearError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.successMessage?.let { message ->
|
||||||
|
LaunchedEffect(message) {
|
||||||
|
// You could show a Snackbar here
|
||||||
|
kotlinx.coroutines.delay(3000)
|
||||||
|
onEvent(ProfileScreenEvent.ClearSuccess)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProfileImageSection(
|
||||||
|
profileImageUrl: String?,
|
||||||
|
isEditing: Boolean,
|
||||||
|
onSelectImage: () -> Unit,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(120.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.border(2.dp, MaterialTheme.colorScheme.primary, CircleShape)
|
||||||
|
.clickable(enabled = isEditing) { onSelectImage() }
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = ImageRequest.Builder(LocalContext.current)
|
||||||
|
.data(profileImageUrl)
|
||||||
|
.placeholder(android.R.drawable.ic_menu_gallery)
|
||||||
|
.error(android.R.drawable.ic_menu_gallery)
|
||||||
|
.crossfade(true)
|
||||||
|
.build(),
|
||||||
|
contentDescription = "Profile Picture",
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = 0.5f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.CameraAlt,
|
||||||
|
contentDescription = "Change Photo",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UserInfoSection(
|
||||||
|
state: UserProfileScreenState,
|
||||||
|
onEvent: (ProfileScreenEvent) -> Unit,
|
||||||
|
) {
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Personal Information",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
// Full Name Field
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.fullNameInput,
|
||||||
|
onValueChange = { onEvent(ProfileScreenEvent.UpdateFullName(it)) },
|
||||||
|
label = { Text("Full Name") },
|
||||||
|
enabled = state.isEditing,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onNext = { focusManager.moveFocus(FocusDirection.Down) }
|
||||||
|
),
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(Icons.Default.Person, contentDescription = null)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Email Field
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.emailInput,
|
||||||
|
onValueChange = { onEvent(ProfileScreenEvent.UpdateEmail(it)) },
|
||||||
|
label = { Text("Email") },
|
||||||
|
enabled = state.isEditing,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Email,
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onNext = { focusManager.moveFocus(FocusDirection.Down) }
|
||||||
|
),
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(Icons.Default.Email, contentDescription = null)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Username Field
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.usernameInput,
|
||||||
|
onValueChange = { onEvent(ProfileScreenEvent.UpdateUsername(it)) },
|
||||||
|
label = { Text("Username") },
|
||||||
|
enabled = state.isEditing,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = { focusManager.clearFocus() }
|
||||||
|
),
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(Icons.Default.AccountCircle, contentDescription = null)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PasswordChangeSection(
|
||||||
|
onStartChangingPassword: () -> Unit,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Security",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onStartChangingPassword,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Lock, contentDescription = null)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Change Password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PasswordChangeForm(
|
||||||
|
state: UserProfileScreenState,
|
||||||
|
onEvent: (ProfileScreenEvent) -> Unit,
|
||||||
|
) {
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
var showCurrentPassword by remember { mutableStateOf(false) }
|
||||||
|
var showNewPassword by remember { mutableStateOf(false) }
|
||||||
|
var showConfirmPassword by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Change Password",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
// Current Password
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.currentPasswordInput,
|
||||||
|
onValueChange = { onEvent(ProfileScreenEvent.UpdateCurrentPassword(it)) },
|
||||||
|
label = { Text("Current Password") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
visualTransformation = if (showCurrentPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onNext = { focusManager.moveFocus(FocusDirection.Down) }
|
||||||
|
),
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(Icons.Default.Lock, contentDescription = null)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { showCurrentPassword = !showCurrentPassword }) {
|
||||||
|
Icon(
|
||||||
|
if (showCurrentPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||||
|
contentDescription = if (showCurrentPassword) "Hide password" else "Show password"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// New Password
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.newPasswordInput,
|
||||||
|
onValueChange = { onEvent(ProfileScreenEvent.UpdateNewPassword(it)) },
|
||||||
|
label = { Text("New Password") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
visualTransformation = if (showNewPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onNext = { focusManager.moveFocus(FocusDirection.Down) }
|
||||||
|
),
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(Icons.Default.VpnKey, contentDescription = null)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { showNewPassword = !showNewPassword }) {
|
||||||
|
Icon(
|
||||||
|
if (showNewPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||||
|
contentDescription = if (showNewPassword) "Hide password" else "Show password"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
supportingText = {
|
||||||
|
Text("Minimum 8 characters")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Confirm Password
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.confirmPasswordInput,
|
||||||
|
onValueChange = { onEvent(ProfileScreenEvent.UpdateConfirmPassword(it)) },
|
||||||
|
label = { Text("Confirm New Password") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
visualTransformation = if (showConfirmPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = { focusManager.clearFocus() }
|
||||||
|
),
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(Icons.Default.VpnKey, contentDescription = null)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { showConfirmPassword = !showConfirmPassword }) {
|
||||||
|
Icon(
|
||||||
|
if (showConfirmPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||||
|
contentDescription = if (showConfirmPassword) "Hide password" else "Show password"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isError = state.newPasswordInput.isNotEmpty() &&
|
||||||
|
state.confirmPasswordInput.isNotEmpty() &&
|
||||||
|
state.newPasswordInput != state.confirmPasswordInput,
|
||||||
|
supportingText = {
|
||||||
|
if (state.newPasswordInput.isNotEmpty() &&
|
||||||
|
state.confirmPasswordInput.isNotEmpty() &&
|
||||||
|
state.newPasswordInput != state.confirmPasswordInput) {
|
||||||
|
Text(
|
||||||
|
text = "Passwords do not match",
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Action Buttons
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onEvent(ProfileScreenEvent.CancelChangingPassword) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { onEvent(ProfileScreenEvent.ChangePassword) },
|
||||||
|
enabled = state.isPasswordFormValid && !state.isLoading,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
if (state.isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Change Password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AdditionalInfoSection(
|
||||||
|
user: UserProfile?,
|
||||||
|
) {
|
||||||
|
user?.let {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Account Information",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
InfoRow(
|
||||||
|
label = "Role",
|
||||||
|
value = if (user.admin) "Administrator" else "User",
|
||||||
|
icon = Icons.Default.Shield
|
||||||
|
)
|
||||||
|
|
||||||
|
user.group?.let { group ->
|
||||||
|
InfoRow(
|
||||||
|
label = "Group",
|
||||||
|
value = group,
|
||||||
|
icon = Icons.Default.Group
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
user.household?.let { household ->
|
||||||
|
InfoRow(
|
||||||
|
label = "Household",
|
||||||
|
value = household,
|
||||||
|
icon = Icons.Default.Home
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InfoRow(
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||