12 Commits
0.5.0 ... main

108 changed files with 3608 additions and 1469 deletions

View File

@@ -1,6 +1,7 @@
MIT License
Copyright (c) 2022, Kirill Kamakin
Copyright (c) 2025, Atridad Lahiji
Permission is hereby granted, free of charge, to any person obtaining a copy
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,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
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
SOFTWARE.

View File

@@ -1,29 +1,26 @@
# Mealient
## DISCLAIMER
## USAGE REQUIREMENTS
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.
- Android 8.0 or higher
- A Mealie server running v3 or higher
## DOWNLOAD
You have two options:
1. Download the latest APK from the Released page
2. [<img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Obtainium" height="41">](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)
## 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?
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
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.
## 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.

View File

@@ -13,11 +13,10 @@ plugins {
android {
defaultConfig {
applicationId = "com.atridad.mealient"
versionCode = 37
versionName = "0.5.0"
versionCode = 39
versionName = "1.0.0"
testInstrumentationRunner = "com.atridad.mealient.MealientTestRunner"
testInstrumentationRunnerArguments += mapOf("clearPackageData" to "true")
resourceConfigurations += listOf("en", "es", "ru", "fr", "nl", "pt", "de")
}
signingConfigs {
@@ -77,6 +76,7 @@ dependencies {
implementation(project(":logging"))
implementation(project(":ui"))
implementation(project(":features:shopping_lists"))
implementation(project(":features:user_managment"))
implementation(project(":model_mapper"))
implementation(libs.android.material.material)
implementation(libs.androidx.coreKtx)
@@ -103,6 +103,7 @@ dependencies {
kover(project(":datasource"))
kover(project(":datastore"))
kover(project(":features:shopping_lists"))
kover(project(":features:user_managment"))
kover(project(":logging"))
kover(project(":model_mapper"))
kover(project(":ui"))

Binary file not shown.

Binary file not shown.

View File

@@ -11,8 +11,8 @@
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 37,
"versionName": "0.5.0",
"versionCode": 38,
"versionName": "0.5.1",
"outputFile": "app-release.apk"
}
],

View File

@@ -11,9 +11,11 @@ import com.atridad.mealient.datasource.models.ParseRecipeURLRequest
import com.atridad.mealient.model_mapper.ModelMapper
import javax.inject.Inject
class MealieDataSourceWrapper @Inject constructor(
private val dataSource: MealieDataSource,
private val modelMapper: ModelMapper,
class MealieDataSourceWrapper
@Inject
constructor(
private val dataSource: MealieDataSource,
private val modelMapper: ModelMapper,
) : AddRecipeDataSource, RecipeDataSource, ParseRecipeDataSource {
override suspend fun addRecipe(recipe: AddRecipeInfo): String {
@@ -23,10 +25,11 @@ class MealieDataSourceWrapper @Inject constructor(
}
override suspend fun requestRecipes(
start: Int,
limit: Int,
start: Int,
limit: Int,
): 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
return dataSource.requestRecipes(page, limit)
}
@@ -40,11 +43,31 @@ class MealieDataSourceWrapper @Inject constructor(
}
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) {
val userId = dataSource.requestUserInfo().id
val userInfo = dataSource.requestUserInfo()
val userId = userInfo.id
if (isFavorite) {
dataSource.addFavoriteRecipe(userId, recipeSlug)
} else {
@@ -55,4 +78,4 @@ class MealieDataSourceWrapper @Inject constructor(
override suspend fun deleteRecipe(recipeSlug: String) {
dataSource.deleteRecipe(recipeSlug)
}
}
}

View File

@@ -11,30 +11,34 @@ import com.atridad.mealient.database.recipe.entity.RecipeSummaryEntity
import com.atridad.mealient.datasource.runCatchingExceptCancel
import com.atridad.mealient.logging.Logger
import com.atridad.mealient.model_mapper.ModelMapper
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
@OptIn(ExperimentalPagingApi::class)
@Singleton
class RecipesRemoteMediator @Inject constructor(
private val storage: RecipeStorage,
private val network: RecipeDataSource,
private val pagingSourceFactory: RecipePagingSourceFactory,
private val logger: Logger,
private val modelMapper: ModelMapper,
private val dispatchers: AppDispatchers,
class RecipesRemoteMediator
@Inject
constructor(
private val storage: RecipeStorage,
private val network: RecipeDataSource,
private val pagingSourceFactory: RecipePagingSourceFactory,
private val logger: Logger,
private val modelMapper: ModelMapper,
private val dispatchers: AppDispatchers,
) : RemoteMediator<Int, RecipeSummaryEntity>() {
@VisibleForTesting
var lastRequestEnd: Int = 0
@VisibleForTesting var lastRequestEnd: Int = 0
override suspend fun load(
loadType: LoadType, state: PagingState<Int, RecipeSummaryEntity>
loadType: LoadType,
state: PagingState<Int, RecipeSummaryEntity>
): 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) {
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 limit = if (loadType == REFRESH) state.config.initialLoadSize else state.config.pageSize
val count: Int = runCatchingExceptCancel {
updateRecipes(start, limit, loadType)
}.getOrElse {
logger.e(it) { "load: can't load recipes" }
return MediatorResult.Error(it)
}
val count: Int =
runCatchingExceptCancel { updateRecipes(start, limit, loadType) }.getOrElse {
logger.e(it) { "load: can't load recipes" }
return MediatorResult.Error(it)
}
// 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
// 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()
logger.d { "load: expectedCount = $limit, received $count" }
@@ -63,25 +67,30 @@ class RecipesRemoteMediator @Inject constructor(
}
suspend fun updateRecipes(
start: Int,
limit: Int,
loadType: LoadType = REFRESH,
start: Int,
limit: Int,
loadType: LoadType = REFRESH,
): Int = coroutineScope {
logger.v { "updateRecipes() called with: start = $start, limit = $limit, loadType = $loadType" }
val deferredRecipes = async { network.requestRecipes(start, limit) }
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)
}
logger.v {
"updateRecipes() called with: start = $start, limit = $limit, loadType = $loadType"
}
if (loadType == REFRESH) storage.refreshAll(entities)
else storage.saveRecipes(entities)
val deferredRecipes = async { network.requestRecipes(start, limit) }
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
}
}
}

View File

@@ -11,6 +11,8 @@ interface PreferencesStorage {
val lastExecutedMigrationVersionKey: Preferences.Key<Int>
val themeModeKey: Preferences.Key<String>
suspend fun <T> getValue(key: Preferences.Key<T>): T?
suspend fun <T> requireValue(key: Preferences.Key<T>): T

View File

@@ -29,6 +29,9 @@ class PreferencesStorageImpl @Inject constructor(
override val lastExecutedMigrationVersionKey: Preferences.Key<Int> =
intPreferencesKey("lastExecutedMigrationVersion")
override val themeModeKey: Preferences.Key<String> =
stringPreferencesKey("themeMode")
override suspend fun <T> getValue(key: Preferences.Key<T>): T? {
val value = dataStore.data.first()[key]
logger.v { "getValue() returned: $value for $key" }

View File

@@ -11,6 +11,8 @@ import com.atridad.mealient.ui.destinations.BaseURLScreenDestination
import com.atridad.mealient.ui.destinations.DisclaimerScreenDestination
import com.atridad.mealient.ui.destinations.RecipeScreenDestination
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 {
@@ -40,6 +42,8 @@ internal object NavGraphs {
DisclaimerScreenDestination,
BaseURLScreenDestination,
AuthenticationScreenDestination,
SettingsScreenDestination,
UserProfileScreenDestination,
),
nestedNavGraphs = listOf(
recipes,

View File

@@ -4,8 +4,9 @@ import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Logout
import androidx.compose.material.icons.automirrored.filled.List
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.SyncAlt
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.BaseURLScreenDestination
import com.atridad.mealient.ui.destinations.RecipesListDestination
import com.mealient.user_management.ui.profile.destinations.UserProfileScreenDestination
import kotlinx.coroutines.launch
@Composable
@@ -78,7 +80,7 @@ internal fun createDrawerItems(
return listOf(
createNavigationItem(
nameRes = R.string.menu_navigation_drawer_recipes_list,
icon = Icons.Default.List,
icon = Icons.AutoMirrored.Filled.List,
direction = RecipesListDestination,
),
createNavigationItem(
@@ -91,6 +93,11 @@ internal fun createDrawerItems(
icon = Icons.Default.ShoppingCart,
direction = NavGraphs.shoppingLists,
),
createNavigationItem(
nameRes = R.string.menu_navigation_drawer_profile,
icon = Icons.Default.Person,
direction = UserProfileScreenDestination,
),
createNavigationItem(
nameRes = R.string.menu_navigation_drawer_change_url,
icon = Icons.Default.SyncAlt,
@@ -98,7 +105,7 @@ internal fun createDrawerItems(
),
createActionItem(
nameRes = R.string.menu_navigation_drawer_logout,
icon = Icons.Default.Logout,
icon = Icons.AutoMirrored.Filled.Logout,
appEvent = AppEvent.Logout,
),
createActionItem(

View File

@@ -4,12 +4,16 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.runtime.collectAsState
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowInsetsControllerCompat
import dagger.hilt.android.AndroidEntryPoint
import com.atridad.mealient.extensions.isDarkThemeOn
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
@AndroidEntryPoint
@@ -20,20 +24,33 @@ class MainActivity : ComponentActivity() {
private val viewModel by viewModels<MainActivityViewModel>()
@Inject lateinit var prefs: PreferencesStorage
override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen()
super.onCreate(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)) {
val isAppearanceLightBars = !isDarkThemeOn()
isAppearanceLightNavigationBars = isAppearanceLightBars
isAppearanceLightStatusBars = isAppearanceLightBars
}
splashScreen.setKeepOnScreenCondition {
viewModel.appState.value.forcedRoute == ForcedDestination.Undefined
}
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)
}
}

View File

@@ -1,15 +1,36 @@
package com.atridad.mealient.ui.activity
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.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.TextButton
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.foundation.layout.WindowInsets
import androidx.navigation.NavHostController
import com.ramcosta.composedestinations.DestinationsNavHost
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.Route
import com.ramcosta.composedestinations.utils.currentDestinationAsState
import com.atridad.mealient.R
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
internal fun MealientApp(
@@ -49,6 +77,22 @@ private fun MealientApp(
val currentDestinationState = controller.currentDestinationAsState()
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(
currentDestination = currentDestination,
controller = controller,
@@ -94,38 +138,48 @@ private fun MealientDialog(
dialogState: DialogState,
onEvent: (AppEvent) -> Unit,
) {
AlertDialog(
androidx.compose.material3.AlertDialog(
onDismissRequest = {
onEvent(dialogState.onDismiss)
},
confirmButton = {
TextButton(
androidx.compose.material3.TextButton(
onClick = { onEvent(dialogState.onPositiveClick) },
) {
Text(
text = stringResource(id = dialogState.positiveButton),
style = androidx.compose.material3.MaterialTheme.typography.labelLarge
)
}
},
dismissButton = {
TextButton(
androidx.compose.material3.TextButton(
onClick = { onEvent(dialogState.onNegativeClick) },
) {
Text(
text = stringResource(id = dialogState.negativeButton),
style = androidx.compose.material3.MaterialTheme.typography.labelLarge
)
}
},
title = {
Text(
text = stringResource(id = dialogState.title),
style = androidx.compose.material3.MaterialTheme.typography.headlineSmall,
color = androidx.compose.material3.MaterialTheme.colorScheme.onSurface
)
},
text = {
Text(
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?,
onEvent: (AppEvent) -> Unit,
) {
val drawerItems = createDrawerItems(
navController = controller,
onEvent = onEvent,
)
val baseScreenState = rememberBaseScreenState(
drawerItems = drawerItems,
)
val currentDestination by controller.currentDestinationAsState()
val view = LocalView.current
val bottomBarColor = androidx.compose.material3.MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
val lightNavIcons = bottomBarColor.luminance() > 0.5f
androidx.compose.runtime.SideEffect {
val window = (view.context as android.app.Activity).window
window.navigationBarColor = bottomBarColor.toArgb()
val controllerInsets = WindowCompat.getInsetsController(window, view)
controllerInsets.isAppearanceLightNavigationBars = lightNavIcons
}
Scaffold(
contentWindowInsets = WindowInsets(0.dp),
bottomBar = {
BottomAppBar(
windowInsets = WindowInsets(0.dp),
containerColor = bottomBarColor,
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
}
}
)
DestinationsNavHost(
navGraph = NavGraphs.root,
engine = engine,
navController = controller,
startRoute = startRoute ?: NavGraphs.root.startRoute,
dependenciesContainerBuilder = {
dependency(baseScreenState)
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)
)
}
}

View File

@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
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.lazy.LazyColumn
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.ui.AppTheme
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.previewBaseScreenState
import com.atridad.mealient.ui.preview.ColorSchemePreview
@Destination
@Composable
internal fun AddRecipeScreen(
baseScreenState: BaseScreenState,
viewModel: AddRecipeViewModel = hiltViewModel()
) {
val screenState by viewModel.screenState.collectAsState()
AddRecipeScreen(
baseScreenState = baseScreenState,
state = screenState,
onEvent = viewModel::onEvent,
)
@@ -55,7 +51,6 @@ internal fun AddRecipeScreen(
@Composable
internal fun AddRecipeScreen(
baseScreenState: BaseScreenState,
state: AddRecipeScreenState,
onEvent: (AddRecipeScreenEvent) -> Unit,
) {
@@ -74,19 +69,14 @@ internal fun AddRecipeScreen(
snackbarHostState.currentSnackbarData?.dismiss()
}
BaseScreenWithNavigation(
baseScreenState = baseScreenState,
snackbarHostState = snackbarHostState,
) { modifier ->
TopProgressIndicator(
modifier = modifier,
isLoading = state.isLoading,
) {
AddRecipeScreenContent(
state = state,
onEvent = onEvent,
)
}
TopProgressIndicator(
modifier = Modifier.fillMaxSize(),
isLoading = state.isLoading,
) {
AddRecipeScreenContent(
state = state,
onEvent = onEvent,
)
}
}
@@ -304,7 +294,6 @@ private fun AddRecipeInputField(
private fun AddRecipeScreenPreview() {
AppTheme {
AddRecipeScreen(
baseScreenState = previewBaseScreenState(),
state = AddRecipeScreenState(),
onEvent = {},
)

View File

@@ -30,18 +30,13 @@ import com.ramcosta.composedestinations.annotation.Destination
import com.atridad.mealient.R
import com.atridad.mealient.ui.AppTheme
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.previewBaseScreenState
import com.atridad.mealient.ui.preview.ColorSchemePreview
@Destination
@Composable
internal fun BaseURLScreen(
navController: NavController,
baseScreenState: BaseScreenState,
viewModel: BaseURLViewModel = hiltViewModel(),
) {
val screenState by viewModel.screenState.collectAsState()
@@ -54,7 +49,6 @@ internal fun BaseURLScreen(
BaseURLScreen(
state = screenState,
baseScreenState = baseScreenState,
onEvent = viewModel::onEvent,
)
}
@@ -62,27 +56,13 @@ internal fun BaseURLScreen(
@Composable
private fun BaseURLScreen(
state: BaseURLScreenState,
baseScreenState: BaseScreenState,
onEvent: (BaseURLScreenEvent) -> Unit,
) {
val content: @Composable (Modifier) -> Unit = {
BaseURLScreen(
modifier = it,
state = state,
onEvent = onEvent,
)
}
if (state.isNavigationEnabled) {
BaseScreenWithNavigation(
baseScreenState = baseScreenState,
content = content,
)
} else {
BaseScreen(
content = content,
)
}
BaseURLScreen(
modifier = Modifier.fillMaxSize(),
state = state,
onEvent = onEvent,
)
}
@Composable
@@ -177,29 +157,7 @@ private fun UrlInputField(
keyboardOptions = KeyboardOptions(
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 = {},
)
}
}

View File

@@ -4,14 +4,25 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.rememberScrollState
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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.hilt.navigation.compose.hiltViewModel
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.components.BaseScreen
import com.atridad.mealient.ui.preview.ColorSchemePreview
@@ -25,11 +36,19 @@ data class RecipeScreenArgs(
)
@Composable
internal fun RecipeScreen(
navigator: DestinationsNavigator,
viewModel: RecipeInfoViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsState()
BaseScreen { modifier ->
BaseScreen(
topAppBar = {
RecipeTopAppBar(
title = state.title ?: "Recipe",
onNavigateBack = { navigator.navigateUp() }
)
}
) { modifier ->
RecipeScreen(
modifier = modifier,
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
private fun RecipeScreen(
state: RecipeInfoUiState,
@@ -74,7 +126,7 @@ private fun RecipeScreen(
@ColorSchemePreview
@Composable
private fun RecipeScreenPreview() {
AppTheme {
MealientTheme {
RecipeScreen(
state = RecipeInfoUiState(
showIngredients = true,

View File

@@ -3,9 +3,11 @@ package com.atridad.mealient.ui.recipes.list
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.ButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.atridad.mealient.R
@Composable
@@ -23,9 +25,13 @@ internal fun ConfirmDeleteDialog(
onClick = {
onConfirm(item)
},
colors = ButtonDefaults.textButtonColors(
contentColor = androidx.compose.material3.MaterialTheme.colorScheme.error
)
) {
Text(
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 = stringResource(id = R.string.fragment_recipes_delete_recipe_confirm_dialog_negative_btn),
style = androidx.compose.material3.MaterialTheme.typography.labelLarge
)
}
},
title = {
Text(
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 = {
@@ -49,7 +58,13 @@ internal fun ConfirmDeleteDialog(
id = R.string.fragment_recipes_delete_recipe_confirm_dialog_message,
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)
)
}

View File

@@ -26,8 +26,10 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import coil.compose.AsyncImage
import com.atridad.mealient.R
import com.atridad.mealient.ui.AppTheme
import com.atridad.mealient.ui.Dimens
import com.atridad.mealient.ui.theme.MealientTheme
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.recipes.info.SUMMARY_ENTITY
import kotlin.random.Random
@@ -42,15 +44,24 @@ internal fun RecipeItem(
) {
Card(
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(
modifier = Modifier
.clickable(onClick = onItemClick)
.padding(
horizontal = Dimens.Medium,
vertical = Dimens.Small,
horizontal = Spacing.md,
vertical = Spacing.sm,
),
verticalArrangement = Arrangement.spacedBy(Dimens.Small),
verticalArrangement = Arrangement.spacedBy(Spacing.sm),
) {
RecipeHeader(
onDeleteClick = onDeleteClick,
@@ -66,7 +77,9 @@ internal fun RecipeItem(
text = recipe.entity.name,
maxLines = 1,
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
.fillMaxWidth()
.aspectRatio(2f) // 2:1
.clip(RoundedCornerShape(Dimens.Intermediate)),
.clip(RoundedCornerShape(BorderRadius.md)),
model = recipe.imageUrl,
contentDescription = stringResource(id = R.string.content_description_fragment_recipe_info_image),
placeholder = imageFallback,
@@ -105,6 +118,10 @@ private fun RecipeHeader(
) {
IconButton(
onClick = onDeleteClick,
colors = androidx.compose.material3.IconButtonDefaults.iconButtonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer
)
) {
Icon(
imageVector = Icons.Default.Delete,
@@ -115,6 +132,18 @@ private fun RecipeHeader(
if (recipe.showFavoriteIcon) {
IconButton(
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(
imageVector = if (recipe.entity.isFavorite) {
@@ -137,7 +166,7 @@ private fun RecipeHeader(
@Composable
private fun RecipeItemPreview() {
val isFavorite = Random.nextBoolean()
AppTheme {
MealientTheme {
RecipeItem(
recipe = RecipeListItemState(null, isFavorite, SUMMARY_ENTITY),
onDeleteClick = {},

View File

@@ -1,18 +1,18 @@
package com.atridad.mealient.ui.recipes.list
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
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.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DrawerState
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -22,8 +22,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import androidx.paging.LoadState
@@ -34,22 +34,28 @@ import androidx.paging.compose.itemKey
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.navigate
import com.atridad.mealient.R
import com.atridad.mealient.ui.AppTheme
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.theme.Spacing
import com.atridad.mealient.ui.theme.BorderRadius
import com.atridad.mealient.ui.components.BaseScreen
import com.atridad.mealient.ui.components.CenteredProgressIndicator
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.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
@Composable
internal fun RecipesList(
navController: NavController,
baseScreenState: BaseScreenState,
viewModel: RecipesListViewModel = hiltViewModel(),
) {
val state = viewModel.screenState.collectAsState()
@@ -64,76 +70,140 @@ internal fun RecipesList(
RecipesList(
state = stateValue,
baseScreenState = baseScreenState,
onEvent = viewModel::onEvent,
navController = navController,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RecipesList(
state: RecipeListState,
baseScreenState: BaseScreenState,
onEvent: (RecipeListEvent) -> Unit,
navController: NavController,
) {
val recipes: LazyPagingItems<RecipeListItemState> =
state.pagingDataRecipeState.collectAsLazyPagingItems()
val isRefreshing = recipes.loadState.refresh is LoadState.Loading
var itemToDelete: RecipeListItemState? by remember { mutableStateOf(null) }
val snackbarHostState = remember { SnackbarHostState() }
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
BaseScreenWithNavigation(
baseScreenState = baseScreenState,
drawerState = drawerState,
topAppBar = {
RecipesTopAppBar(
searchQuery = state.searchQuery,
onValueChanged = { onEvent(RecipeListEvent.SearchQueryChanged(it)) },
drawerState = drawerState,
)
},
state.snackbarState?.let { snackbar ->
val message = snackbar.message
LaunchedEffect(message) {
snackbarHostState.showSnackbar(message)
onEvent(RecipeListEvent.SnackbarShown)
}
} ?: run {
snackbarHostState.currentSnackbarData?.dismiss()
}
itemToDelete?.let { item ->
ConfirmDeleteDialog(
onDismissRequest = { itemToDelete = null },
onConfirm = {
onEvent(RecipeListEvent.DeleteConfirmed(item))
itemToDelete = null
},
item = item,
)
}
BaseScreen(
snackbarHostState = snackbarHostState,
) { modifier ->
state.snackbarState?.message?.let { message ->
LaunchedEffect(message) {
snackbarHostState.showSnackbar(message)
onEvent(RecipeListEvent.SnackbarShown)
}
} ?: run {
snackbarHostState.currentSnackbarData?.dismiss()
}
itemToDelete?.let { item ->
ConfirmDeleteDialog(
onDismissRequest = { itemToDelete = null },
onConfirm = {
onEvent(RecipeListEvent.DeleteConfirmed(item))
itemToDelete = null
},
item = item,
)
}
when {
recipes.itemCount != 0 -> {
RecipesListData(
modifier = modifier,
recipes = recipes,
onDeleteClick = { itemToDelete = it },
onFavoriteClick = { onEvent(RecipeListEvent.FavoriteClick(it)) },
onItemClick = { onEvent(RecipeListEvent.RecipeClick(it)) },
Box(modifier = modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize()
) {
// Clean, spacious top app bar
androidx.compose.material3.TopAppBar(
title = {
androidx.compose.material3.Text(
text = stringResource(R.string.menu_navigation_drawer_recipes_list),
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onSurface
)
},
colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface
)
)
}
isRefreshing -> {
CenteredProgressIndicator(
modifier = modifier
// Full-width search bar with proper Material 3 spacing
androidx.compose.material3.OutlinedTextField(
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 -> {
RecipesListError(
modifier = modifier,
recipes = recipes,
// FAB for adding recipes
androidx.compose.material3.FloatingActionButton(
onClick = {
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
.fillMaxSize(),
lazyPagingItems = recipes,
verticalArrangement = Arrangement.spacedBy(Dimens.Medium),
contentPadding = PaddingValues(Dimens.Medium),
verticalArrangement = Arrangement.spacedBy(Spacing.md),
contentPadding = PaddingValues(Spacing.md),
) {
items(
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),
)
}
}

View File

@@ -5,13 +5,14 @@ import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.map
import dagger.hilt.android.lifecycle.HiltViewModel
import com.atridad.mealient.architecture.valueUpdatesOnly
import com.atridad.mealient.data.auth.AuthRepo
import com.atridad.mealient.data.recipes.RecipeRepo
import com.atridad.mealient.data.recipes.impl.RecipeImageUrlProvider
import com.atridad.mealient.database.recipe.entity.RecipeSummaryEntity
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.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -23,44 +24,48 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
internal class RecipesListViewModel @Inject constructor(
private val recipeRepo: RecipeRepo,
private val logger: Logger,
private val recipeImageUrlProvider: RecipeImageUrlProvider,
authRepo: AuthRepo,
internal class RecipesListViewModel
@Inject
constructor(
private val recipeRepo: RecipeRepo,
private val logger: Logger,
private val recipeImageUrlProvider: RecipeImageUrlProvider,
authRepo: AuthRepo,
) : ViewModel() {
private val pagingData: Flow<PagingData<RecipeSummaryEntity>> =
recipeRepo.createPager().flow.cachedIn(viewModelScope)
recipeRepo.createPager().flow.cachedIn(viewModelScope)
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>> =
pagingData.combine(showFavoriteIcon) { data, showFavorite ->
data.map { item ->
val imageUrl = recipeImageUrlProvider.generateImageUrl(item.imageId)
RecipeListItemState(
imageUrl = imageUrl,
showFavoriteIcon = showFavorite,
entity = item,
)
pagingData.combine(showFavoriteIcon) { data, showFavorite ->
data.map { item ->
val imageUrl = recipeImageUrlProvider.generateImageUrl(item.imageId)
RecipeListItemState(
imageUrl = imageUrl,
showFavoriteIcon = showFavorite,
entity = item,
)
}
}
}
private val _screenState = MutableStateFlow(
RecipeListState(pagingDataRecipeState = pagingDataRecipeState)
)
val screenState: StateFlow<RecipeListState> get() = _screenState.asStateFlow()
private val _screenState =
MutableStateFlow(RecipeListState(pagingDataRecipeState = pagingDataRecipeState))
val screenState: StateFlow<RecipeListState>
get() = _screenState.asStateFlow()
init {
authRepo.isAuthorizedFlow.valueUpdatesOnly().onEach { hasAuthorized ->
logger.v { "Authorization state changed to $hasAuthorized" }
if (hasAuthorized) recipeRepo.refreshRecipes()
}.launchIn(viewModelScope)
authRepo.isAuthorizedFlow
.valueUpdatesOnly()
.onEach { hasAuthorized ->
logger.v { "Authorization state changed to $hasAuthorized" }
if (hasAuthorized) recipeRepo.refreshRecipes()
}
.launchIn(viewModelScope)
}
private fun onRecipeClicked(entity: RecipeSummaryEntity) {
@@ -75,23 +80,23 @@ internal class RecipesListViewModel @Inject constructor(
private fun onFavoriteIconClick(recipeSummaryEntity: RecipeSummaryEntity) {
logger.v { "onFavoriteIconClick() called with: recipeSummaryEntity = $recipeSummaryEntity" }
viewModelScope.launch {
val result = recipeRepo.updateIsRecipeFavorite(
recipeSlug = recipeSummaryEntity.slug,
isFavorite = recipeSummaryEntity.isFavorite.not(),
)
val snackbar = result.fold(
onSuccess = { isFavorite ->
val name = recipeSummaryEntity.name
if (isFavorite) {
RecipeListSnackbar.FavoriteAdded(name)
} else {
RecipeListSnackbar.FavoriteRemoved(name)
}
},
onFailure = {
RecipeListSnackbar.FavoriteUpdateFailed
}
)
val result =
recipeRepo.updateIsRecipeFavorite(
recipeSlug = recipeSummaryEntity.slug,
isFavorite = recipeSummaryEntity.isFavorite.not(),
)
val snackbar =
result.fold(
onSuccess = { _ ->
val name = recipeSummaryEntity.name
if (recipeSummaryEntity.isFavorite) {
RecipeListSnackbar.FavoriteRemoved(name)
} else {
RecipeListSnackbar.FavoriteAdded(name)
}
},
onFailure = { RecipeListSnackbar.FavoriteUpdateFailed }
)
_screenState.update { it.copy(snackbarState = snackbar) }
}
}
@@ -101,10 +106,11 @@ internal class RecipesListViewModel @Inject constructor(
viewModelScope.launch {
val result = recipeRepo.deleteRecipe(recipeSummaryEntity)
logger.d { "onDeleteConfirm: delete result is $result" }
val snackbar = result.fold(
onSuccess = { null },
onFailure = { RecipeListSnackbar.DeleteFailed },
)
val snackbar =
result.fold(
onSuccess = { null },
onFailure = { RecipeListSnackbar.DeleteFailed },
)
_screenState.update { it.copy(snackbarState = snackbar) }
}
}
@@ -135,13 +141,15 @@ internal class RecipesListViewModel @Inject constructor(
_screenState.update { it.copy(searchQuery = event.query) }
recipeRepo.updateNameQuery(event.query)
}
}
internal data class RecipeListState(
val pagingDataRecipeState: Flow<PagingData<RecipeListItemState>>,
val snackbarState: RecipeListSnackbar? = null,
val recipeIdToOpen: String? = null,
val searchQuery: String = "",
val pagingDataRecipeState: Flow<PagingData<RecipeListItemState>>,
val snackbarState: RecipeListSnackbar? = null,
val recipeIdToOpen: String? = null,
val searchQuery: String = "",
)
internal sealed interface RecipeListEvent {
@@ -157,4 +165,6 @@ internal sealed interface RecipeListEvent {
data object SnackbarShown : RecipeListEvent
data class SearchQueryChanged(val query: String) : RecipeListEvent
}
}

View File

@@ -16,8 +16,9 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
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
@Composable
@@ -27,7 +28,7 @@ internal fun SearchTextField(
@StringRes placeholder: Int,
modifier: Modifier = Modifier,
) {
TextField(
androidx.compose.material3.OutlinedTextField(
modifier = modifier
.semantics { testTag = "search-recipes-field" },
value = searchQuery,
@@ -35,12 +36,15 @@ internal fun SearchTextField(
placeholder = {
Text(
text = stringResource(id = placeholder),
style = androidx.compose.material3.MaterialTheme.typography.bodyMedium,
color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant
)
},
leadingIcon = {
Icon(
imageVector = Icons.Default.Search,
contentDescription = null,
contentDescription = stringResource(R.string.search_recipes_hint),
tint = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant
)
},
keyboardOptions = KeyboardOptions(
@@ -50,23 +54,28 @@ internal fun SearchTextField(
onSearch = { defaultKeyboardAction(ImeAction.Done) }
),
singleLine = true,
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent
)
textStyle = androidx.compose.material3.MaterialTheme.typography.bodyLarge,
colors = androidx.compose.material3.OutlinedTextFieldDefaults.colors(
focusedBorderColor = androidx.compose.material3.MaterialTheme.colorScheme.primary,
unfocusedBorderColor = androidx.compose.material3.MaterialTheme.colorScheme.outline,
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
@Composable
private fun SearchTextFieldPreview() {
AppTheme {
MealientTheme {
SearchTextField(
searchQuery = "",
onValueChanged = {},
placeholder = R.string.search_recipes_hint,
)
}
}
}

View File

@@ -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
)
}
}

View File

@@ -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
}

View File

@@ -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,
}

View File

@@ -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
)
}
}
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -1,9 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.App.Starting" parent="Theme.SplashScreen">
<item name="postSplashScreenTheme">@style/AppTheme</item>
<item name="windowSplashScreenBackground">@android:color/black</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash_screen</item>
</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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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_positive_btn">Confirm</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="search_recipes_hint">Search recipes</string>
<string name="view_toolbar_navigation_icon_content_description">Open navigation drawer</string>

View File

@@ -1,10 +1,10 @@
<resources>
<style name="Theme.App.Starting" parent="Theme.SplashScreen">
<item name="postSplashScreenTheme">@style/AppTheme</item>
<item name="windowSplashScreenBackground">@android:color/white</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash_screen</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowSplashScreenBrandingImage">@null</item>
</style>
<style name="AppTheme" parent="Theme.Material3.DynamicColors.DayNight">
@@ -12,5 +12,7 @@
<item name="windowNoTitle">true</item>
<item name="android:statusBarColor">@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>
</resources>

View File

@@ -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)
}
}
}
}

View File

@@ -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
}
}
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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
}
}
}
}

View File

@@ -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"))
}
}
}

View File

@@ -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()
}

View File

@@ -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)
}
}

View File

@@ -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")

View File

@@ -8,7 +8,7 @@ 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 = 34
const val COMPILE_SDK_VERSION = 36
}
val Project.libs: VersionCatalog

View File

@@ -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"
}
]

View File

@@ -2,15 +2,18 @@ package com.atridad.mealient.database
import androidx.room.TypeConverter
import kotlinx.datetime.*
import kotlin.time.ExperimentalTime
object RoomTypeConverters {
@OptIn(ExperimentalTime::class)
@TypeConverter
fun localDateTimeToTimestamp(localDateTime: LocalDateTime) =
localDateTime.toInstant(TimeZone.UTC).toEpochMilliseconds()
@OptIn(ExperimentalTime::class)
@TypeConverter
fun timestampToLocalDateTime(timestamp: Long) =
Instant.fromEpochMilliseconds(timestamp).toLocalDateTime(TimeZone.UTC)
kotlin.time.Instant.fromEpochMilliseconds(timestamp).toLocalDateTime(TimeZone.UTC)
@TypeConverter
fun localDateToTimeStamp(date: LocalDate) =

View File

@@ -9,22 +9,21 @@ internal interface RecipeDao {
@Query("SELECT * FROM recipe_summaries ORDER BY recipe_summaries_date_added DESC")
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>
@Transaction
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertRecipeSummaries(recipeSummaryEntity: Iterable<RecipeSummaryEntity>)
@Transaction
@Query("DELETE FROM recipe_summaries")
suspend fun removeAllRecipes()
@Transaction @Query("DELETE FROM recipe_summaries") suspend fun removeAllRecipes()
@Query("SELECT * FROM recipe_summaries ORDER BY recipe_summaries_date_added DESC")
suspend fun queryAllRecipes(): List<RecipeSummaryEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertRecipe(recipe: RecipeEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertRecipe(recipe: RecipeEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertRecipes(recipe: List<RecipeEntity>)
@@ -36,19 +35,25 @@ internal interface RecipeDao {
suspend fun insertRecipeIngredients(ingredients: List<RecipeIngredientEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertIngredientToInstructionEntities(entities: List<RecipeIngredientToInstructionEntity>)
suspend fun insertIngredientToInstructionEntities(
entities: List<RecipeIngredientToInstructionEntity>
)
@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(
"SELECT * FROM recipe " +
"JOIN recipe_summaries USING(recipe_id) " +
"LEFT JOIN recipe_ingredient USING(recipe_id) " +
"LEFT JOIN recipe_instruction USING(recipe_id) " +
"LEFT JOIN recipe_ingredient_to_instruction USING(recipe_id) " +
"WHERE recipe.recipe_id = :recipeId"
"SELECT * FROM recipe " +
"JOIN recipe_summaries USING(recipe_id) " +
"LEFT JOIN recipe_ingredient USING(recipe_id) " +
"LEFT JOIN recipe_instruction USING(recipe_id) " +
"LEFT JOIN recipe_ingredient_to_instruction USING(recipe_id) " +
"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)")
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)")
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>)
@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>)
@Delete
suspend fun deleteRecipe(entity: RecipeSummaryEntity)
}
@Delete suspend fun deleteRecipe(entity: RecipeSummaryEntity)
}

View File

@@ -12,10 +12,12 @@ import com.atridad.mealient.database.recipe.entity.RecipeWithSummaryAndIngredien
import com.atridad.mealient.logging.Logger
import javax.inject.Inject
internal class RecipeStorageImpl @Inject constructor(
private val db: AppDb,
private val logger: Logger,
private val recipeDao: RecipeDao,
internal class RecipeStorageImpl
@Inject
constructor(
private val db: AppDb,
private val logger: Logger,
private val recipeDao: RecipeDao,
) : RecipeStorage {
override suspend fun saveRecipes(recipes: List<RecipeSummaryEntity>) {
@@ -43,12 +45,14 @@ internal class RecipeStorageImpl @Inject constructor(
}
override suspend fun saveRecipeInfo(
recipe: RecipeEntity,
ingredients: List<RecipeIngredientEntity>,
instructions: List<RecipeInstructionEntity>,
ingredientToInstruction: List<RecipeIngredientToInstructionEntity>,
recipe: RecipeEntity,
ingredients: List<RecipeIngredientEntity>,
instructions: List<RecipeInstructionEntity>,
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 {
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" }
val fullRecipeInfo = recipeDao.queryFullRecipeInfo(recipeId)
logger.v { "queryRecipeInfo() returned: $fullRecipeInfo" }
@@ -73,8 +79,12 @@ internal class RecipeStorageImpl @Inject constructor(
override suspend fun updateFavoriteRecipes(favorites: List<String>) {
logger.v { "updateFavoriteRecipes() called with: favorites = $favorites" }
db.withTransaction {
recipeDao.setFavorite(favorites)
recipeDao.setNonFavorite(favorites)
if (favorites.isNotEmpty()) {
recipeDao.setFavorite(favorites)
recipeDao.setNonFavorite(favorites)
} else {
recipeDao.setAllNonFavorite()
}
}
}
@@ -82,4 +92,4 @@ internal class RecipeStorageImpl @Inject constructor(
logger.v { "deleteRecipeBySlug() called with: entity = $entity" }
recipeDao.deleteRecipe(entity)
}
}
}

View File

@@ -12,47 +12,51 @@ import com.atridad.mealient.datasource.models.GetShoppingListItemResponse
import com.atridad.mealient.datasource.models.GetShoppingListResponse
import com.atridad.mealient.datasource.models.GetShoppingListsResponse
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.ParseRecipeURLRequest
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
interface MealieDataSource {
suspend fun createRecipe(
recipe: CreateRecipeRequest,
recipe: CreateRecipeRequest,
): String
suspend fun updateRecipe(
slug: String,
recipe: UpdateRecipeRequest,
slug: String,
recipe: UpdateRecipeRequest,
): GetRecipeResponse
/**
* Tries to acquire authentication token using the provided credentials
*/
/** Tries to acquire authentication token using the provided credentials */
suspend fun authenticate(
username: String,
password: String,
username: String,
password: String,
): String
suspend fun getVersionInfo(baseURL: String): VersionResponse
suspend fun requestRecipes(
page: Int,
perPage: Int,
page: Int,
perPage: Int,
): List<GetRecipeSummaryResponse>
suspend fun requestRecipeInfo(
slug: String,
slug: String,
): GetRecipeResponse
suspend fun parseRecipeFromURL(
request: ParseRecipeURLRequest,
request: ParseRecipeURLRequest,
): String
suspend fun createApiToken(
request: CreateApiTokenRequest,
request: CreateApiTokenRequest,
): CreateApiTokenResponse
suspend fun requestUserInfo(): GetUserInfoResponse
@@ -82,4 +86,15 @@ interface MealieDataSource {
suspend fun deleteShoppingList(id: 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)
}

View File

@@ -12,10 +12,16 @@ import com.atridad.mealient.datasource.models.GetShoppingListResponse
import com.atridad.mealient.datasource.models.GetShoppingListsResponse
import com.atridad.mealient.datasource.models.GetTokenResponse
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.ParseRecipeURLRequest
import com.atridad.mealient.datasource.models.UpdateRecipeRequest
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
internal interface MealieService {
@@ -25,8 +31,8 @@ internal interface MealieService {
suspend fun createRecipe(addRecipeRequest: CreateRecipeRequest): String
suspend fun updateRecipe(
addRecipeRequest: UpdateRecipeRequest,
slug: String,
addRecipeRequest: UpdateRecipeRequest,
slug: String,
): GetRecipeResponse
suspend fun getVersion(baseURL: String): VersionResponse
@@ -68,6 +74,17 @@ internal interface MealieService {
suspend fun deleteShoppingList(id: String)
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)
}

View File

@@ -17,258 +17,357 @@ import com.atridad.mealient.datasource.models.GetShoppingListItemResponse
import com.atridad.mealient.datasource.models.GetShoppingListResponse
import com.atridad.mealient.datasource.models.GetShoppingListsResponse
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.ParseRecipeURLRequest
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 io.ktor.client.call.NoTransformationFoundException
import io.ktor.client.call.body
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.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import java.net.SocketException
import java.net.SocketTimeoutException
import javax.inject.Inject
internal class MealieDataSourceImpl @Inject constructor(
private val networkRequestWrapper: NetworkRequestWrapper,
private val service: MealieService,
internal class MealieDataSourceImpl
@Inject
constructor(
private val networkRequestWrapper: NetworkRequestWrapper,
private val service: MealieService,
) : MealieDataSource {
override suspend fun createRecipe(
recipe: CreateRecipeRequest,
): String = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createRecipe(recipe) },
logMethod = { "createRecipe" },
logParameters = { "recipe = $recipe" }
).trim('"')
recipe: CreateRecipeRequest,
): String =
networkRequestWrapper
.makeCallAndHandleUnauthorized(
block = { service.createRecipe(recipe) },
logMethod = { "createRecipe" },
logParameters = { "recipe = $recipe" }
)
.trim('"')
override suspend fun updateRecipe(
slug: String,
recipe: UpdateRecipeRequest,
): GetRecipeResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.updateRecipe(recipe, slug) },
logMethod = { "updateRecipe" },
logParameters = { "slug = $slug, recipe = $recipe" }
)
slug: String,
recipe: UpdateRecipeRequest,
): GetRecipeResponse =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.updateRecipe(recipe, slug) },
logMethod = { "updateRecipe" },
logParameters = { "slug = $slug, recipe = $recipe" }
)
override suspend fun authenticate(
username: String,
password: String,
): String = networkRequestWrapper.makeCall(
block = { service.getToken(username, password) },
logMethod = { "authenticate" },
logParameters = { "username = $username, password = $password" }
).map { it.accessToken }.getOrElse {
val errorDetail = (it as? ResponseException)?.response?.body<ErrorDetail>() ?: throw it
throw if (errorDetail.detail == "Unauthorized") NetworkError.Unauthorized(it) else it
}
username: String,
password: String,
): String =
networkRequestWrapper
.makeCall(
block = { service.getToken(username, password) },
logMethod = { "authenticate" },
logParameters = { "username = $username, password = $password" }
)
.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 =
networkRequestWrapper.makeCall(
block = { service.getVersion(baseURL) },
logMethod = { "getVersionInfo" },
logParameters = { "baseURL = $baseURL" }
).getOrElse {
throw when (it) {
is ResponseException, is NoTransformationFoundException -> NetworkError.NotMealie(it)
is SocketTimeoutException, is SocketException -> NetworkError.NoServerConnection(it)
else -> NetworkError.MalformedUrl(it)
}
}
networkRequestWrapper.makeCall(
block = { service.getVersion(baseURL) },
logMethod = { "getVersionInfo" },
logParameters = { "baseURL = $baseURL" }
)
.getOrElse {
throw when (it) {
is ResponseException, is NoTransformationFoundException ->
NetworkError.NotMealie(it)
is SocketTimeoutException, is SocketException ->
NetworkError.NoServerConnection(it)
else -> NetworkError.MalformedUrl(it)
}
}
override suspend fun requestRecipes(
page: Int,
perPage: Int,
): List<GetRecipeSummaryResponse> = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getRecipeSummary(page, perPage) },
logMethod = { "requestRecipes" },
logParameters = { "page = $page, perPage = $perPage" }
).items
page: Int,
perPage: Int,
): List<GetRecipeSummaryResponse> {
val response =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getRecipeSummary(page, perPage) },
logMethod = { "requestRecipes" },
logParameters = { "page = $page, perPage = $perPage" }
)
return response.items
}
override suspend fun requestRecipeInfo(
slug: String,
): GetRecipeResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getRecipe(slug) },
logMethod = { "requestRecipeInfo" },
logParameters = { "slug = $slug" }
)
slug: String,
): GetRecipeResponse =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getRecipe(slug) },
logMethod = { "requestRecipeInfo" },
logParameters = { "slug = $slug" }
)
override suspend fun parseRecipeFromURL(
request: ParseRecipeURLRequest,
): String = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createRecipeFromURL(request) },
logMethod = { "parseRecipeFromURL" },
logParameters = { "request = $request" }
)
request: ParseRecipeURLRequest,
): String =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createRecipeFromURL(request) },
logMethod = { "parseRecipeFromURL" },
logParameters = { "request = $request" }
)
override suspend fun createApiToken(
request: CreateApiTokenRequest,
): CreateApiTokenResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createApiToken(request) },
logMethod = { "createApiToken" },
logParameters = { "request = $request" }
)
request: CreateApiTokenRequest,
): CreateApiTokenResponse =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createApiToken(request) },
logMethod = { "createApiToken" },
logParameters = { "request = $request" }
)
override suspend fun requestUserInfo(): GetUserInfoResponse {
return networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getUserSelfInfo() },
logMethod = { "requestUserInfo" },
)
val response =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getUserSelfInfo() },
logMethod = { "requestUserInfo" },
)
return response
}
override suspend fun removeFavoriteRecipe(
userId: String,
recipeSlug: String,
): Unit = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.removeFavoriteRecipe(userId, recipeSlug) },
logMethod = { "removeFavoriteRecipe" },
logParameters = { "userId = $userId, recipeSlug = $recipeSlug" }
)
userId: String,
recipeSlug: String,
): Unit {
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.removeFavoriteRecipe(userId, recipeSlug) },
logMethod = { "removeFavoriteRecipe" },
logParameters = { "userId = $userId, recipeSlug = $recipeSlug" }
)
}
override suspend fun addFavoriteRecipe(
userId: String,
recipeSlug: String,
): Unit = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.addFavoriteRecipe(userId, recipeSlug) },
logMethod = { "addFavoriteRecipe" },
logParameters = { "userId = $userId, recipeSlug = $recipeSlug" }
)
userId: String,
recipeSlug: String,
): Unit {
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.addFavoriteRecipe(userId, recipeSlug) },
logMethod = { "addFavoriteRecipe" },
logParameters = { "userId = $userId, recipeSlug = $recipeSlug" }
)
}
override suspend fun deleteRecipe(
slug: String,
): Unit = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.deleteRecipe(slug) },
logMethod = { "deleteRecipe" },
logParameters = { "slug = $slug" }
)
slug: String,
): Unit =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.deleteRecipe(slug) },
logMethod = { "deleteRecipe" },
logParameters = { "slug = $slug" }
)
override suspend fun getShoppingLists(
page: Int,
perPage: Int,
): GetShoppingListsResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getShoppingLists(page, perPage) },
logMethod = { "getShoppingLists" },
logParameters = { "page = $page, perPage = $perPage" }
)
page: Int,
perPage: Int,
): GetShoppingListsResponse =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getShoppingLists(page, perPage) },
logMethod = { "getShoppingLists" },
logParameters = { "page = $page, perPage = $perPage" }
)
override suspend fun getShoppingList(
id: String,
): GetShoppingListResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getShoppingList(id) },
logMethod = { "getShoppingList" },
logParameters = { "id = $id" }
)
id: String,
): GetShoppingListResponse =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getShoppingList(id) },
logMethod = { "getShoppingList" },
logParameters = { "id = $id" }
)
private suspend fun getShoppingListItem(
id: String,
): JsonElement = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getShoppingListItem(id) },
logMethod = { "getShoppingListItem" },
logParameters = { "id = $id" }
)
id: String,
): JsonElement =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getShoppingListItem(id) },
logMethod = { "getShoppingListItem" },
logParameters = { "id = $id" }
)
private suspend fun updateShoppingListItem(
id: String,
request: JsonElement,
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.updateShoppingListItem(id, request) },
logMethod = { "updateShoppingListItem" },
logParameters = { "id = $id, request = $request" }
)
id: String,
request: JsonElement,
) =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.updateShoppingListItem(id, request) },
logMethod = { "updateShoppingListItem" },
logParameters = { "id = $id, request = $request" }
)
override suspend fun deleteShoppingListItem(
id: String,
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.deleteShoppingListItem(id) },
logMethod = { "deleteShoppingListItem" },
logParameters = { "id = $id" }
)
id: String,
) =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.deleteShoppingListItem(id) },
logMethod = { "deleteShoppingListItem" },
logParameters = { "id = $id" }
)
override suspend fun updateShoppingListItem(
item: GetShoppingListItemResponse,
item: GetShoppingListItemResponse,
) {
// Has to be done in two steps because we can't specify only the changed fields
val remoteItem = getShoppingListItem(item.id)
val updatedItem = remoteItem.jsonObject.toMutableMap().apply {
put("checked", JsonPrimitive(item.checked))
put("isFood", JsonPrimitive(item.isFood))
put("note", JsonPrimitive(item.note))
put("quantity", JsonPrimitive(item.quantity))
put("foodId", JsonPrimitive(item.food?.id))
put("unitId", JsonPrimitive(item.unit?.id))
remove("unit")
remove("food")
}
val updatedItem =
remoteItem.jsonObject.toMutableMap().apply {
put("checked", JsonPrimitive(item.checked))
put("isFood", JsonPrimitive(item.isFood))
put("note", JsonPrimitive(item.note))
put("quantity", JsonPrimitive(item.quantity))
put("foodId", JsonPrimitive(item.food?.id))
put("unitId", JsonPrimitive(item.unit?.id))
remove("unit")
remove("food")
}
updateShoppingListItem(item.id, JsonObject(updatedItem))
}
override suspend fun getFoods(): GetFoodsResponse {
return networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getFoods(perPage = -1) },
logMethod = { "getFoods" },
block = { service.getFoods(perPage = -1) },
logMethod = { "getFoods" },
)
}
override suspend fun getUnits(): GetUnitsResponse {
return networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getUnits(perPage = -1) },
logMethod = { "getUnits" },
block = { service.getUnits(perPage = -1) },
logMethod = { "getUnits" },
)
}
override suspend fun addShoppingListItem(
request: CreateShoppingListItemRequest,
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createShoppingListItem(request) },
logMethod = { "addShoppingListItem" },
logParameters = { "request = $request" }
)
request: CreateShoppingListItemRequest,
) =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createShoppingListItem(request) },
logMethod = { "addShoppingListItem" },
logParameters = { "request = $request" }
)
override suspend fun addShoppingList(
request: CreateShoppingListRequest,
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createShoppingList(request) },
logMethod = { "createShoppingList" },
logParameters = { "request = $request" }
)
request: CreateShoppingListRequest,
) =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createShoppingList(request) },
logMethod = { "createShoppingList" },
logParameters = { "request = $request" }
)
private suspend fun updateShoppingList(
id: String,
request: JsonElement,
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.updateShoppingList(id, request) },
logMethod = { "updateShoppingList" },
logParameters = { "id = $id, request = $request" }
)
id: String,
request: JsonElement,
) =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.updateShoppingList(id, request) },
logMethod = { "updateShoppingList" },
logParameters = { "id = $id, request = $request" }
)
private suspend fun getShoppingListJson(
id: String,
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getShoppingListJson(id) },
logMethod = { "getShoppingListJson" },
logParameters = { "id = $id" }
)
id: String,
) =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getShoppingListJson(id) },
logMethod = { "getShoppingListJson" },
logParameters = { "id = $id" }
)
override suspend fun deleteShoppingList(
id: String,
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.deleteShoppingList(id) },
logMethod = { "deleteShoppingList" },
logParameters = { "id = $id" }
)
id: String,
) =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.deleteShoppingList(id) },
logMethod = { "deleteShoppingList" },
logParameters = { "id = $id" }
)
override suspend fun updateShoppingListName(
id: String,
name: String
) {
override suspend fun updateShoppingListName(id: String, name: String) {
// Has to be done in two steps because we can't specify only the changed fields
val remoteItem = getShoppingListJson(id)
val updatedItem = remoteItem.jsonObject.toMutableMap().apply {
put("name", JsonPrimitive(name))
}.let(::JsonObject)
val updatedItem =
remoteItem
.jsonObject
.toMutableMap()
.apply { put("name", JsonPrimitive(name)) }
.let(::JsonObject)
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" }
)
}
}

View File

@@ -14,33 +14,45 @@ import com.atridad.mealient.datasource.models.GetShoppingListResponse
import com.atridad.mealient.datasource.models.GetShoppingListsResponse
import com.atridad.mealient.datasource.models.GetTokenResponse
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.ParseRecipeURLRequest
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 io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.delete
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.patch
import io.ktor.client.request.post
import io.ktor.client.request.put
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.Headers
import io.ktor.http.HttpHeaders
import io.ktor.http.URLBuilder
import io.ktor.http.contentType
import io.ktor.http.parameters
import io.ktor.http.path
import io.ktor.http.takeFrom
import kotlinx.serialization.json.JsonElement
import javax.inject.Inject
import javax.inject.Provider
import kotlinx.serialization.json.JsonElement
internal class MealieServiceKtor @Inject constructor(
private val httpClient: HttpClient,
private val serverUrlProviderProvider: Provider<ServerUrlProvider>,
internal class MealieServiceKtor
@Inject
constructor(
private val httpClient: HttpClient,
private val serverUrlProviderProvider: Provider<ServerUrlProvider>,
) : MealieService {
private val serverUrlProvider: ServerUrlProvider
@@ -52,111 +64,109 @@ internal class MealieServiceKtor @Inject constructor(
append("password", password)
}
return httpClient.post {
endpoint("/api/auth/token")
setBody(FormDataContent(formParameters))
}.body()
return httpClient
.post {
endpoint("/api/auth/token")
setBody(FormDataContent(formParameters))
}
.body()
}
override suspend fun createRecipe(addRecipeRequest: CreateRecipeRequest): String {
return httpClient.post {
endpoint("/api/recipes")
contentType(ContentType.Application.Json)
setBody(addRecipeRequest)
}.body()
return httpClient
.post {
endpoint("/api/recipes")
contentType(ContentType.Application.Json)
setBody(addRecipeRequest)
}
.body()
}
override suspend fun updateRecipe(
addRecipeRequest: UpdateRecipeRequest,
slug: String,
addRecipeRequest: UpdateRecipeRequest,
slug: String,
): GetRecipeResponse {
return httpClient.patch {
endpoint("/api/recipes/$slug")
contentType(ContentType.Application.Json)
setBody(addRecipeRequest)
}.body()
return httpClient
.patch {
endpoint("/api/recipes/$slug")
contentType(ContentType.Application.Json)
setBody(addRecipeRequest)
}
.body()
}
override suspend fun getVersion(baseURL: String): VersionResponse {
return httpClient.get {
endpoint(baseURL, "/api/app/about")
}.body()
return httpClient.get { endpoint(baseURL, "/api/app/about") }.body()
}
override suspend fun getRecipeSummary(page: Int, perPage: Int): GetRecipesResponse {
return httpClient.get {
endpoint("/api/recipes") {
parameters.append("page", page.toString())
parameters.append("perPage", perPage.toString())
}
}.body()
return httpClient
.get {
endpoint("/api/recipes") {
parameters.append("page", page.toString())
parameters.append("perPage", perPage.toString())
}
}
.body()
}
override suspend fun getRecipe(slug: String): GetRecipeResponse {
return httpClient.get {
endpoint("/api/recipes/$slug")
}.body()
return httpClient.get { endpoint("/api/recipes/$slug") }.body()
}
override suspend fun createRecipeFromURL(request: ParseRecipeURLRequest): String {
return httpClient.post {
endpoint("/api/recipes/create-url")
contentType(ContentType.Application.Json)
setBody(request)
}.body()
return httpClient
.post {
endpoint("/api/recipes/create-url")
contentType(ContentType.Application.Json)
setBody(request)
}
.body()
}
override suspend fun createApiToken(request: CreateApiTokenRequest): CreateApiTokenResponse {
return httpClient.post {
endpoint("/api/users/api-tokens")
contentType(ContentType.Application.Json)
setBody(request)
}.body()
return httpClient
.post {
endpoint("/api/users/api-tokens")
contentType(ContentType.Application.Json)
setBody(request)
}
.body()
}
override suspend fun getUserSelfInfo(): GetUserInfoResponse {
return httpClient.get {
endpoint("/api/users/self")
}.body()
return httpClient.get { endpoint("/api/users/self") }.body()
}
override suspend fun removeFavoriteRecipe(userId: String, recipeSlug: String) {
httpClient.delete {
endpoint("/api/users/$userId/favorites/$recipeSlug")
}
httpClient.delete { endpoint("/api/users/$userId/favorites/$recipeSlug") }
}
override suspend fun addFavoriteRecipe(userId: String, recipeSlug: String) {
httpClient.post {
endpoint("/api/users/$userId/favorites/$recipeSlug")
}
httpClient.post { endpoint("/api/users/$userId/favorites/$recipeSlug") }
}
override suspend fun deleteRecipe(slug: String) {
httpClient.delete {
endpoint("/api/recipes/$slug")
}
httpClient.delete { endpoint("/api/recipes/$slug") }
}
override suspend fun getShoppingLists(page: Int, perPage: Int): GetShoppingListsResponse {
return httpClient.get {
endpoint("/api/households/shopping/lists") {
parameters.append("page", page.toString())
parameters.append("perPage", perPage.toString())
}
}.body()
return httpClient
.get {
endpoint("/api/households/shopping/lists") {
parameters.append("page", page.toString())
parameters.append("perPage", perPage.toString())
}
}
.body()
}
override suspend fun getShoppingList(id: String): GetShoppingListResponse {
return httpClient.get {
endpoint("/api/households/shopping/lists/$id")
}.body()
return httpClient.get { endpoint("/api/households/shopping/lists/$id") }.body()
}
override suspend fun getShoppingListItem(id: String): JsonElement {
return httpClient.get {
endpoint("/api/households/shopping/items/$id")
}.body()
return httpClient.get { endpoint("/api/households/shopping/items/$id") }.body()
}
override suspend fun updateShoppingListItem(id: String, request: JsonElement) {
@@ -168,25 +178,19 @@ internal class MealieServiceKtor @Inject constructor(
}
override suspend fun deleteShoppingListItem(id: String) {
httpClient.delete {
endpoint("/api/households/shopping/items/$id")
}
httpClient.delete { endpoint("/api/households/shopping/items/$id") }
}
override suspend fun getFoods(perPage: Int): GetFoodsResponse {
return httpClient.get {
endpoint("/api/foods") {
parameters.append("perPage", perPage.toString())
}
}.body()
return httpClient
.get { endpoint("/api/foods") { parameters.append("perPage", perPage.toString()) } }
.body()
}
override suspend fun getUnits(perPage: Int): GetUnitsResponse {
return httpClient.get {
endpoint("/api/units") {
parameters.append("perPage", perPage.toString())
}
}.body()
return httpClient
.get { endpoint("/api/units") { parameters.append("perPage", perPage.toString()) } }
.body()
}
override suspend fun createShoppingListItem(request: CreateShoppingListItemRequest) {
@@ -206,9 +210,7 @@ internal class MealieServiceKtor @Inject constructor(
}
override suspend fun deleteShoppingList(id: String) {
httpClient.delete {
endpoint("/api/households/shopping/lists/$id")
}
httpClient.delete { endpoint("/api/households/shopping/lists/$id") }
}
override suspend fun updateShoppingList(id: String, request: JsonElement) {
@@ -220,27 +222,66 @@ internal class MealieServiceKtor @Inject constructor(
}
override suspend fun getShoppingListJson(id: String): JsonElement {
return httpClient.get {
endpoint("/api/households/shopping/lists/$id")
}.body()
return httpClient.get { 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(
path: String,
block: URLBuilder.() -> Unit = {},
path: String,
block: URLBuilder.() -> Unit = {},
) {
val baseUrl = checkNotNull(serverUrlProvider.getUrl()) { "Server URL is not set" }
endpoint(
baseUrl = baseUrl,
path = path,
block = block
)
endpoint(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(
baseUrl: String,
path: String,
block: URLBuilder.() -> Unit = {},
baseUrl: String,
path: String,
block: URLBuilder.() -> Unit = {},
) {
url {
takeFrom(baseUrl)
@@ -248,4 +289,6 @@ internal class MealieServiceKtor @Inject constructor(
block()
}
}
}

View File

@@ -15,12 +15,6 @@ internal class TokenChangeListenerKtor @Inject constructor(
override fun onTokenChange() {
logger.v { "onTokenChange() called" }
httpClient.plugin(Auth)
.providers
.filterIsInstance<BearerAuthProvider>()
.forEach {
logger.d { "onTokenChange(): removing the token" }
it.clearToken()
}
logger.d { "onTokenChange(): token change requested, will use new token on next request" }
}
}

View File

@@ -15,7 +15,7 @@ data class GetRecipeResponse(
@Serializable
data class GetRecipeSettingsResponse(
@SerialName("disableAmount") val disableAmount: Boolean,
@SerialName("disableAmount") val disableAmount: Boolean = true,
)
@Serializable
@@ -27,8 +27,8 @@ data class GetRecipeIngredientResponse(
@SerialName("display") val display: String,
@SerialName("referenceId") val referenceId: String,
@SerialName("title") val title: String?,
@SerialName("isFood") val isFood: Boolean,
@SerialName("disableAmount") val disableAmount: Boolean,
@SerialName("isFood") val isFood: Boolean = false,
@SerialName("disableAmount") val disableAmount: Boolean = true,
)
@Serializable

View File

@@ -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,
)

View File

@@ -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
}
}

View File

@@ -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.

View File

@@ -1 +0,0 @@
Ingredient amounts and ingredient titles are now supported.

View File

@@ -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.

View File

@@ -1 +0,0 @@
Mealient will fallback to HTTP if HTTPS is not available.

View File

@@ -1,2 +0,0 @@
Added support for nightly versions of Mealie.
Added some UI/UX improvements.

View File

@@ -1,2 +0,0 @@
Added support for per-app language settings.
Added translation to Spanish language.

View File

@@ -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.

View File

@@ -1 +0,0 @@
The app will keep screen on while viewing a recipe

View File

@@ -1 +0,0 @@
Ingredients that are linked to a specific recipe step are shown under that step.

View File

@@ -1 +0,0 @@
Display notes under each recipe ingredient.

View File

@@ -1 +0,0 @@
Removed crash reporting.

View File

@@ -1,2 +0,0 @@
Fix authentication issues with some Mealie instances.
Allow sending logs to the developer.

View File

@@ -1,2 +0,0 @@
Now authentication screen is shown automatically when authentication fails.
The recipe ingredient notes are no longer duplicated.

View File

@@ -1 +0,0 @@
Now you can add new shopping lists as well as rename and remove existing ones.

View File

@@ -1 +0,0 @@
Fixed incompatibility with Mealie v1.11.0.

View File

@@ -1 +0,0 @@
Mealient enables you to easily access the recipes stored in your Mealie instance using your phone.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -1 +0,0 @@
Unofficial client for the self-hosted recipe manager Mealie.

View File

@@ -1 +0,0 @@
Mealient

View File

@@ -6,8 +6,10 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
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.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
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.Check
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.Restaurant
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.ui.AppTheme
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.preview.ColorSchemePreview
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.map
import java.text.DecimalFormat
import androidx.compose.ui.unit.dp
data class ShoppingListNavArgs(
val shoppingListId: String,
@@ -82,25 +87,24 @@ internal fun ShoppingListScreen(
) {
val loadingState by shoppingListViewModel.loadingState.collectAsState()
BaseScreen { modifier ->
ShoppingListScreen(
modifier = modifier,
loadingState = loadingState,
errorToShowInSnackbar = shoppingListViewModel.errorToShowInSnackbar,
onSnackbarShown = shoppingListViewModel::onSnackbarShown,
onRefreshRequest = shoppingListViewModel::refreshShoppingList,
onAddItemClicked = shoppingListViewModel::onAddItemClicked,
onEditCancel = shoppingListViewModel::onEditCancel,
onEditConfirm = shoppingListViewModel::onEditConfirm,
onItemCheckedChange = shoppingListViewModel::onItemCheckedChange,
onDeleteItem = shoppingListViewModel::deleteShoppingListItem,
onEditStart = shoppingListViewModel::onEditStart,
onAddCancel = shoppingListViewModel::onAddCancel,
onAddConfirm = shoppingListViewModel::onAddConfirm,
)
}
ShoppingListScreen(
modifier = Modifier.fillMaxSize(),
loadingState = loadingState,
errorToShowInSnackbar = shoppingListViewModel.errorToShowInSnackbar,
onSnackbarShown = shoppingListViewModel::onSnackbarShown,
onRefreshRequest = shoppingListViewModel::refreshShoppingList,
onAddItemClicked = shoppingListViewModel::onAddItemClicked,
onEditCancel = shoppingListViewModel::onEditCancel,
onEditConfirm = shoppingListViewModel::onEditConfirm,
onItemCheckedChange = shoppingListViewModel::onItemCheckedChange,
onDeleteItem = shoppingListViewModel::deleteShoppingListItem,
onEditStart = shoppingListViewModel::onEditStart,
onAddCancel = shoppingListViewModel::onAddCancel,
onAddConfirm = shoppingListViewModel::onAddConfirm,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ShoppingListScreen(
loadingState: LoadingState<ShoppingListScreenState>,
@@ -117,6 +121,27 @@ private fun ShoppingListScreen(
onAddConfirm: (ShoppingListItemState.NewItem) -> Unit,
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(
R.string.shopping_list_screen_empty_list,
loadingState.data?.name.orEmpty()
@@ -134,7 +159,7 @@ private fun ShoppingListScreen(
}
LazyColumnWithLoadingState(
modifier = modifier,
modifier = modifier.padding(paddingValues),
loadingState = loadingState.map { it.items },
emptyListError = loadingState.error?.let { getErrorMessage(it) } ?: defaultEmptyListError,
retryButtonText = stringResource(id = R.string.shopping_lists_screen_empty_button_refresh),
@@ -149,7 +174,6 @@ private fun ShoppingListScreen(
onSnackbarShown = onSnackbarShown,
onRefresh = onRefreshRequest,
floatingActionButton = {
// Only show the button if the editor is not active to avoid overlapping
if (!itemBeingEdited) {
FloatingActionButton(onClick = onAddItemClicked) {
Icon(
@@ -159,56 +183,70 @@ private fun ShoppingListScreen(
}
}
},
lazyListState = lazyListState
) { sortedItems ->
lazyListState = lazyListState,
lazyColumnContent = { sortedItems ->
lastAddedItemIndex = sortedItems.indexOfLast { it is ShoppingListItemState.NewItem }
val firstCheckedItemIndex = sortedItems.indexOfFirst { it.checked }
lastAddedItemIndex = sortedItems.indexOfLast { it is ShoppingListItemState.NewItem }
val firstCheckedItemIndex = sortedItems.indexOfFirst { it.checked }
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) },
if (sortedItems.isNotEmpty()) {
item(key = "hint") {
Text(
text = "💡 Swipe left to delete, swipe right to edit",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
modifier = Modifier.padding(
horizontal = Dimens.Small,
vertical = Dimens.Small
)
} 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(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
modifier = Modifier.fillMaxWidth()
) {
Checkbox(
checked = itemState.item.checked,
@@ -653,7 +692,6 @@ fun ShoppingListItem(
if (!isFood) {
appendBold(shoppingListItem.note)
} else {
// Add plural unit and food name if available
shoppingListItem.unit?.let { unit ->
appendWithPlural(unit.name, unit.pluralName,
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()
Column {
Column(
modifier = Modifier
.weight(1f)
.padding(end = Dimens.Small)
) {
Text(
text = primaryText,
style = MaterialTheme.typography.bodyLarge,
maxLines = 2,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
)
if (secondaryText.isNotBlank()) {
Text(
text = secondaryText,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
)
}
}
}
}
},

View File

@@ -3,16 +3,20 @@ package com.atridad.mealient.shopping_lists.ui.list
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ShoppingCart
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -26,21 +30,14 @@ import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.navigate
import com.atridad.mealient.shopping_list.R
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.ui.AppTheme
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
@Composable
internal fun ShoppingListsScreen(
navController: NavController,
baseScreenState: BaseScreenState,
shoppingListsViewModel: ShoppingListsViewModel = hiltViewModel(),
) {
val screenState by shoppingListsViewModel.shoppingListsState.collectAsState()
@@ -50,47 +47,76 @@ internal fun ShoppingListsScreen(
onEvent = shoppingListsViewModel::onEvent
)
BaseScreenWithNavigation(
baseScreenState = baseScreenState,
) { modifier ->
LazyColumnWithLoadingState(
modifier = modifier,
loadingState = screenState.loadingState,
emptyListError = screenState.loadingState.error?.let { getErrorMessage(it) }
?: stringResource(R.string.shopping_lists_screen_empty),
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),
Scaffold(
topBar = {
androidx.compose.material3.TopAppBar(
title = {
Text(
text = "Shopping Lists",
style = androidx.compose.material3.MaterialTheme.typography.headlineLarge,
color = androidx.compose.material3.MaterialTheme.colorScheme.onSurface
)
}
},
) { items ->
items(
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))
}
},
colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors(
containerColor = androidx.compose.material3.MaterialTheme.colorScheme.surface,
titleContentColor = androidx.compose.material3.MaterialTheme.colorScheme.onSurface
)
)
},
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,
oldName = dialog.oldListName
)
}
is ShoppingListsDialog.NewListItem -> {
ShoppingListNameDialog(
onEvent = onEvent,
@@ -119,8 +143,6 @@ private fun ShoppingListsScreenDialog(
listName = dialog.listName
)
}
is ShoppingListsDialog.RemoveListItem -> {
DeleteListConfirmDialog(
onEvent = onEvent,
@@ -128,7 +150,6 @@ private fun ShoppingListsScreenDialog(
listName = dialog.listName
)
}
is ShoppingListsDialog.None -> {
Unit
}
@@ -179,8 +200,6 @@ private fun ShoppingListCard(
imageVector = Icons.Default.ShoppingCart,
contentDescription = stringResource(id = R.string.shopping_lists_screen_cart_icon),
)
Text(
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
View File

@@ -0,0 +1 @@
/build

View 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)
}

View File

@@ -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?
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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
)
}
}
}

View File

@@ -0,0 +1,81 @@
package com.mealient.user_management.ui.profile
import androidx.compose.runtime.Immutable
@Immutable
data class UserProfileScreenState(
val isLoading: Boolean = false,
val isEditing: Boolean = false,
val user: UserProfile? = null,
val fullNameInput: String = "",
val emailInput: String = "",
val usernameInput: String = "",
val currentPasswordInput: String = "",
val newPasswordInput: String = "",
val confirmPasswordInput: String = "",
val isChangingPassword: Boolean = false,
val errorMessage: String? = null,
val successMessage: String? = null,
val profileImageUri: String? = null,
) {
val isPasswordFormValid: Boolean
get() = currentPasswordInput.isNotBlank() &&
newPasswordInput.length >= 8 &&
newPasswordInput == confirmPasswordInput
val isProfileFormValid: Boolean
get() = fullNameInput.isNotBlank() &&
emailInput.isNotBlank() &&
android.util.Patterns.EMAIL_ADDRESS.matcher(emailInput).matches()
}
@Immutable
data class UserProfile(
val id: String,
val username: String?,
val fullName: String?,
val email: String,
val admin: Boolean,
val group: String?,
val household: String?,
val profileImageUrl: String? = null,
)
sealed interface ProfileScreenEvent {
object LoadProfile : ProfileScreenEvent
object StartEditing : ProfileScreenEvent
object CancelEditing : ProfileScreenEvent
object SaveProfile : ProfileScreenEvent
object StartChangingPassword : ProfileScreenEvent
object CancelChangingPassword : ProfileScreenEvent
object ChangePassword : ProfileScreenEvent
object SelectProfileImage : ProfileScreenEvent
object ClearError : ProfileScreenEvent
object ClearSuccess : ProfileScreenEvent
data class UpdateFullName(val value: String) : ProfileScreenEvent
data class UpdateEmail(val value: String) : ProfileScreenEvent
data class UpdateUsername(val value: String) : ProfileScreenEvent
data class UpdateCurrentPassword(val value: String) : ProfileScreenEvent
data class UpdateNewPassword(val value: String) : ProfileScreenEvent
data class UpdateConfirmPassword(val value: String) : ProfileScreenEvent
data class UpdateProfileImage(val imageBytes: ByteArray, val fileName: String) : ProfileScreenEvent {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as UpdateProfileImage
if (!imageBytes.contentEquals(other.imageBytes)) return false
if (fileName != other.fileName) return false
return true
}
override fun hashCode(): Int {
var result = imageBytes.contentHashCode()
result = 31 * result + fileName.hashCode()
return result
}
}
}

View File

@@ -0,0 +1,321 @@
package com.mealient.user_management.ui.profile
import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.atridad.mealient.datasource.models.UpdateUserProfileRequest
import com.atridad.mealient.datasource.models.toUpdateRequest
import com.atridad.mealient.datasource.models.ChangePasswordRequest
import com.atridad.mealient.datasource.models.UpdateProfileImageRequest
import com.atridad.mealient.datasource.models.UserProfileResponse
import com.atridad.mealient.logging.Logger
import com.mealient.user_management.data.UserProfileRepository
import com.atridad.mealient.datasource.ServerUrlProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class UserProfileViewModel @Inject constructor(
private val userProfileRepository: UserProfileRepository,
private val logger: Logger,
private val application: Application,
private val serverUrlProvider: ServerUrlProvider,
) : ViewModel() {
private val _screenState = MutableStateFlow(UserProfileScreenState())
val screenState: StateFlow<UserProfileScreenState> = _screenState.asStateFlow()
init {
loadProfile()
}
fun onEvent(event: ProfileScreenEvent) {
when (event) {
ProfileScreenEvent.LoadProfile -> loadProfile()
ProfileScreenEvent.StartEditing -> startEditing()
ProfileScreenEvent.CancelEditing -> cancelEditing()
ProfileScreenEvent.SaveProfile -> saveProfile()
ProfileScreenEvent.StartChangingPassword -> startChangingPassword()
ProfileScreenEvent.CancelChangingPassword -> cancelChangingPassword()
ProfileScreenEvent.ChangePassword -> changePassword()
ProfileScreenEvent.SelectProfileImage -> selectProfileImage()
ProfileScreenEvent.ClearError -> clearError()
ProfileScreenEvent.ClearSuccess -> clearSuccess()
is ProfileScreenEvent.UpdateFullName -> updateFullName(event.value)
is ProfileScreenEvent.UpdateEmail -> updateEmail(event.value)
is ProfileScreenEvent.UpdateUsername -> updateUsername(event.value)
is ProfileScreenEvent.UpdateCurrentPassword -> updateCurrentPassword(event.value)
is ProfileScreenEvent.UpdateNewPassword -> updateNewPassword(event.value)
is ProfileScreenEvent.UpdateConfirmPassword -> updateConfirmPassword(event.value)
is ProfileScreenEvent.UpdateProfileImage -> updateProfileImage(event.imageBytes, event.fileName)
}
}
private fun loadProfile() {
logger.v { "loadProfile() called" }
_screenState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
try {
val profile = userProfileRepository.getUserProfile()
val userProfile = profile.toUserProfile()
_screenState.update {
it.copy(
isLoading = false,
user = userProfile,
fullNameInput = userProfile.fullName ?: "",
emailInput = userProfile.email,
usernameInput = userProfile.username ?: "",
)
}
} catch (e: Exception) {
logger.e(e) { "Failed to load profile" }
_screenState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load profile: ${e.message}"
)
}
}
}
}
private fun startEditing() {
_screenState.update { it.copy(isEditing = true, errorMessage = null) }
}
private fun cancelEditing() {
val currentUser = _screenState.value.user
_screenState.update {
it.copy(
isEditing = false,
fullNameInput = currentUser?.fullName ?: "",
emailInput = currentUser?.email ?: "",
usernameInput = currentUser?.username ?: "",
errorMessage = null
)
}
}
private fun saveProfile() {
logger.v { "saveProfile() called" }
val state = _screenState.value
if (!state.isProfileFormValid) {
_screenState.update { it.copy(errorMessage = "Please fill in all required fields with valid data") }
return
}
_screenState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
try {
// Get current profile to preserve all permissions and settings
val currentProfile = userProfileRepository.getCurrentUserValue()
?: throw IllegalStateException("No current user profile")
// Create update request preserving all existing data except what we want to change
val request = currentProfile.toUpdateRequest(
newFullName = state.fullNameInput.takeIf { it.isNotBlank() },
newEmail = state.emailInput,
newUsername = state.usernameInput.takeIf { it.isNotBlank() },
)
val updatedProfile = userProfileRepository.updateUserProfile(request)
val userProfile = updatedProfile.toUserProfile()
_screenState.update {
it.copy(
isLoading = false,
isEditing = false,
user = userProfile,
successMessage = "Profile updated successfully"
)
}
} catch (e: Exception) {
logger.e(e) { "Failed to save profile" }
_screenState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to update profile: ${e.message}"
)
}
}
}
}
private fun startChangingPassword() {
_screenState.update {
it.copy(
isChangingPassword = true,
currentPasswordInput = "",
newPasswordInput = "",
confirmPasswordInput = "",
errorMessage = null
)
}
}
private fun cancelChangingPassword() {
_screenState.update {
it.copy(
isChangingPassword = false,
currentPasswordInput = "",
newPasswordInput = "",
confirmPasswordInput = "",
errorMessage = null
)
}
}
private fun changePassword() {
logger.v { "changePassword() called" }
val state = _screenState.value
if (!state.isPasswordFormValid) {
_screenState.update {
it.copy(errorMessage = "Password must be at least 8 characters and passwords must match")
}
return
}
_screenState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
try {
val request = ChangePasswordRequest(
currentPassword = state.currentPasswordInput,
newPassword = state.newPasswordInput
)
userProfileRepository.changePassword(request)
_screenState.update {
it.copy(
isLoading = false,
isChangingPassword = false,
currentPasswordInput = "",
newPasswordInput = "",
confirmPasswordInput = "",
successMessage = "Password changed successfully"
)
}
} catch (e: Exception) {
logger.e(e) { "Failed to change password" }
_screenState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to change password: ${e.message}"
)
}
}
}
}
private fun selectProfileImage() {
// This will be handled by the UI layer with image picker
logger.v { "selectProfileImage() called" }
}
private fun updateProfileImage(imageBytes: ByteArray, fileName: String) {
logger.v { "updateProfileImage() called with fileName: $fileName" }
_screenState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
try {
val request = UpdateProfileImageRequest(
imageBytes = imageBytes,
fileName = fileName
)
userProfileRepository.updateProfileImage(request)
_screenState.update {
it.copy(
isLoading = false,
successMessage = "Profile image updated successfully"
)
}
} catch (e: Exception) {
logger.e(e) { "Failed to update profile image" }
_screenState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to update profile image: ${e.message}"
)
}
}
}
}
private fun clearError() {
_screenState.update { it.copy(errorMessage = null) }
}
private fun clearSuccess() {
_screenState.update { it.copy(successMessage = null) }
}
// Input field update methods
private fun updateFullName(value: String) {
_screenState.update { it.copy(fullNameInput = value) }
}
private fun updateEmail(value: String) {
_screenState.update { it.copy(emailInput = value) }
}
private fun updateUsername(value: String) {
_screenState.update { it.copy(usernameInput = value) }
}
private fun updateCurrentPassword(value: String) {
_screenState.update { it.copy(currentPasswordInput = value) }
}
private fun updateNewPassword(value: String) {
_screenState.update { it.copy(newPasswordInput = value) }
}
private fun updateConfirmPassword(value: String) {
_screenState.update { it.copy(confirmPasswordInput = value) }
}
private suspend fun UserProfileResponse.toUserProfile(): UserProfile {
return UserProfile(
id = id,
username = username,
fullName = fullName,
email = email,
admin = admin,
group = group,
household = household,
profileImageUrl = constructProfileImageUrl(id, cacheKey)
)
}
private suspend fun constructProfileImageUrl(userId: String, cacheKey: String?): String? {
return try {
val baseUrl = serverUrlProvider.getUrl()?.takeUnless { it.isEmpty() }
if (baseUrl != null) {
val baseImageUrl = "$baseUrl/api/media/users/$userId/profile.webp"
if (cacheKey != null) {
"$baseImageUrl?cacheKey=$cacheKey"
} else {
baseImageUrl
}
} else {
null
}
} catch (e: Exception) {
logger.w(e) { "Failed to construct profile image URL" }
null
}
}
}

View File

@@ -1,86 +1,86 @@
[versions]
# https://maven.google.com/web/index.html?q=com.android.tools.build#com.android.tools.build:gradle
androidGradlePlugin = "8.5.2"
androidGradlePlugin = "8.12.2"
# https://github.com/JetBrains/kotlin/releases
kotlin = "2.0.10"
kotlin = "2.2.10"
# https://dagger.dev/hilt/gradle-setup
hilt = "2.52"
hilt = "2.57.1"
# https://github.com/protocolbuffers/protobuf/releases
protobuf = "4.27.3"
protobuf = "4.32.0"
# https://github.com/google/protobuf-gradle-plugin/releases
protobufPlugin = "0.9.4"
protobufPlugin = "0.9.5"
# https://plugins.gradle.org/plugin/org.sonarqube
sonarqube = "5.1.0.4882"
sonarqube = "6.3.1.5724"
# https://github.com/material-components/material-components-android/releases
material = "1.12.0"
# https://developer.android.com/kotlin/ktx#core
coreKtx = "1.13.1"
coreKtx = "1.17.0"
# https://developer.android.com/jetpack/androidx/releases/appcompat
appcompat = "1.7.0"
appcompat = "1.7.1"
# https://developer.android.com/jetpack/androidx/releases/core
splashScreen = "1.0.1"
# https://developer.android.com/jetpack/androidx/releases/lifecycle
lifecycle = "2.8.6"
lifecycle = "2.9.3"
# https://developer.android.com/jetpack/androidx/releases/arch-core
coreTesting = "2.2.0"
# https://github.com/Kotlin/kotlinx.serialization/releases
kotlinxSerialization = "1.7.3"
kotlinxSerialization = "1.9.0"
# https://github.com/square/okhttp/tags
okhttp = "4.12.0"
okhttp = "5.1.0"
# https://developer.android.com/jetpack/androidx/releases/paging
paging = "3.3.2"
paging = "3.3.6"
# https://developer.android.com/jetpack/androidx/releases/room
room = "2.6.1"
room = "2.7.2"
# https://github.com/Kotlin/kotlinx-datetime/releases
kotlinxDatetime = "0.6.1"
kotlinxDatetime = "0.7.1"
# https://developer.android.com/jetpack/androidx/releases/datastore
datastore = "1.1.1"
datastore = "1.1.7"
# https://developer.android.com/jetpack/androidx/releases/security
security = "1.0.0"
security = "1.1.0"
# https://github.com/junit-team/junit4/releases
junit = "4.13.2"
# https://github.com/Kotlin/kotlinx.coroutines/releases
coroutines = "1.8.1"
coroutines = "1.10.2"
# https://github.com/robolectric/robolectric/releases
robolectric = "4.13"
robolectric = "4.16"
# https://mvnrepository.com/artifact/com.google.truth/truth
truth = "1.4.4"
# https://mockk.io/
mockk = "1.13.12"
mockk = "1.14.5"
# https://github.com/square/leakcanary/releases
leakcanary = "2.14"
# https://github.com/ChuckerTeam/chucker/releases
chucker = "4.0.0"
chucker = "4.2.0"
# https://github.com/google/desugar_jdk_libs/blob/master/CHANGELOG.md
desugar = "2.1.2"
desugar = "2.1.5"
# https://github.com/google/ksp/releases
kspPlugin = "2.0.10-1.0.24"
kspPlugin = "2.2.10-2.0.2"
# https://developer.android.com/jetpack/androidx/releases/sharetarget
shareTarget = "1.2.0"
# https://github.com/KasperskyLab/Kaspresso/releases
kaspresso = "1.5.4"
kaspresso = "1.6.0"
# https://developer.android.com/jetpack/androidx/releases/test
androidXTestCore = "1.6.1"
androidXTestRules = "1.6.1"
androidXTestRunner = "1.6.2"
androidXTestOrchestrator = "1.5.0"
junitKtx = "1.2.1"
androidXTestCore = "1.7.0"
androidXTestRules = "1.7.0"
androidXTestRunner = "1.7.0"
androidXTestOrchestrator = "1.6.1"
junitKtx = "1.3.0"
# https://mvnrepository.com/artifact/androidx.compose/compose-bom
composeBom = "2024.09.03"
composeBom = "2025.08.01"
# https://google.github.io/accompanist/
accompanistVersion = "0.36.0"
# https://developer.android.com/jetpack/androidx/releases/compose-material
materialCompose = "1.7.3"
materialCompose = "1.9.0"
# https://github.com/raamcosta/compose-destinations/releases
composeDestinations = "1.10.2"
# https://developer.android.com/jetpack/androidx/releases/hilt
androidxHilt = "1.2.0"
# https://github.com/ktorio/ktor/releases
ktor = "2.3.12"
ktor = "3.2.3"
# https://github.com/coil-kt/coil/releases
coil = "2.7.0"
# https://github.com/Kotlin/kotlinx-kover/releases
kover = "0.8.3"
kover = "0.9.1"
[libraries]
android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" }
@@ -171,7 +171,7 @@ chuckerteam-chucker = { group = "com.github.chuckerteam.chucker", name = "librar
kaspersky-kaspresso = { group = "com.kaspersky.android-components", name = "kaspresso", version.ref = "kaspresso" }
kaspersky-kaspresso-compose = { group = "com.kaspersky.android-components", name = "kaspresso-compose-support", version.ref = "kaspresso" }
composeDestinations-core = { group = "io.github.raamcosta.compose-destinations", name = "animations-core", version.ref = "composeDestinations" }
composeDestinations-core = { group = "io.github.raamcosta.compose-destinations", name = "core", version.ref = "composeDestinations" }
composeDestinations-ksp = { group = "io.github.raamcosta.compose-destinations", name = "ksp", version.ref = "composeDestinations" }
ktor-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }

Some files were not shown because too many files have changed in this diff Show More