5 Commits

Author SHA1 Message Date
6da10a0612 Update README.md 2025-09-01 07:19:57 +00:00
2e163f8354 Dependency Updates 2025-08-31 18:24:41 -06:00
d10622c382 1.0.0 - Material you rework 2025-08-31 02:24:26 -06:00
e4ea44f766 Remove unneeded files 2025-08-29 16:00:31 -06:00
7650e6487d Merge pull request 'README' (#3) from feature-profile into main
Reviewed-on: #3
2025-08-12 07:35:06 +00:00
83 changed files with 1733 additions and 1104 deletions

View File

@@ -10,7 +10,7 @@
You have two options:
1. Download the latest APK from the Released page
2. Use <a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.atridad.mealient%22%2C%22url%22%3A%22https%3A%2F%2Fgit.atri.dad%2Fatridad%2FMealient%2Freleases%22%2C%22author%22%3A%22git.atri.dad%22%2C%22name%22%3A%22Mealient%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla%2F5.0%20(Linux%3B%20Android%2010%3B%20K)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F114.0.0.0%20Mobile%20Safari%2F537.36%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22partialAPKHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D">Obtainium</a>
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

View File

@@ -13,11 +13,10 @@ plugins {
android {
defaultConfig {
applicationId = "com.atridad.mealient"
versionCode = 38
versionName = "0.6.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 {

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,7 @@ 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 {
@@ -41,6 +42,7 @@ internal object NavGraphs {
DisclaimerScreenDestination,
BaseURLScreenDestination,
AuthenticationScreenDestination,
SettingsScreenDestination,
UserProfileScreenDestination,
),
nestedNavGraphs = listOf(

View File

@@ -4,8 +4,8 @@ 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
@@ -80,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(
@@ -105,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

@@ -141,6 +141,8 @@ constructor(
_screenState.update { it.copy(searchQuery = event.query) }
recipeRepo.updateNameQuery(event.query)
}
}
internal data class RecipeListState(
@@ -163,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,24 @@ internal fun SearchTextField(
onSearch = { defaultKeyboardAction(ImeAction.Done) }
),
singleLine = true,
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
errorContainerColor = 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 = {},

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

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

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

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

View File

@@ -1,8 +1,5 @@
package com.mealient.user_management.ui.profile
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -13,10 +10,10 @@ 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.automirrored.filled.ArrowBack
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
@@ -25,60 +22,28 @@ 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.res.stringResource
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 androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.InputStream
import androidx.hilt.navigation.compose.hiltViewModel
@Destination
@Composable
fun UserProfileScreen(
navigator: DestinationsNavigator,
viewModel: UserProfileViewModel = hiltViewModel(),
) {
val state by viewModel.screenState.collectAsStateWithLifecycle()
val context = LocalContext.current
val imagePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri: Uri? ->
uri?.let { selectedUri ->
// Convert URI to byte array
// This would typically be done in a background thread
try {
val inputStream: InputStream? = context.contentResolver.openInputStream(selectedUri)
inputStream?.use { stream ->
val bytes = stream.readBytes()
val fileName = "profile_image_${System.currentTimeMillis()}.jpg"
viewModel.onEvent(ProfileScreenEvent.UpdateProfileImage(bytes, fileName))
}
} catch (e: Exception) {
// Handle error
}
}
}
LaunchedEffect(Unit) {
viewModel.onEvent(ProfileScreenEvent.LoadProfile)
}
val state by viewModel.screenState.collectAsState()
UserProfileContent(
state = state,
onEvent = viewModel::onEvent,
onSelectImage = { imagePickerLauncher.launch("image/*") },
onNavigateBack = { navigator.navigateUp() }
onSelectImage = { /* TODO: Implement image selection */ }
)
}
@@ -88,53 +53,53 @@ private fun UserProfileContent(
state: UserProfileScreenState,
onEvent: (ProfileScreenEvent) -> Unit,
onSelectImage: () -> Unit,
onNavigateBack: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
) {
// Top App Bar
TopAppBar(
title = { Text("Profile") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
if (!state.isChangingPassword) {
if (state.isEditing) {
TextButton(onClick = { onEvent(ProfileScreenEvent.CancelEditing) }) {
Text("Cancel")
}
TextButton(
onClick = { onEvent(ProfileScreenEvent.SaveProfile) },
enabled = state.isProfileFormValid && !state.isLoading
) {
Text("Save")
}
} else {
IconButton(onClick = { onEvent(ProfileScreenEvent.StartEditing) }) {
Icon(Icons.Default.Edit, contentDescription = "Edit")
}
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(),
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {

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.9.0"
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" }

View File

@@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=31c55713e40233a8303827ceb42ca48a47267a0ad4bab9177123121e71524c26
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
distributionSha256Sum=f397b287023acdba1e9f6fc5ea72d22dd63669d59ed4a289a29b1a76eee151c6
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

View File

@@ -1,6 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
]
}

View File

@@ -1,17 +1,23 @@
package com.atridad.mealient.ui.components
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.DrawerState
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.Text
import androidx.compose.material3.Divider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.atridad.mealient.ui.Dimens
import com.atridad.mealient.ui.R
import com.atridad.mealient.ui.theme.Spacing
import com.atridad.mealient.ui.theme.BorderRadius
interface DrawerItem {
@@ -32,22 +38,47 @@ internal fun DrawerContent(
drawerState: DrawerState,
drawerItems: List<DrawerItem>,
) {
ModalDrawerSheet {
Text(
ModalDrawerSheet(
drawerContainerColor = MaterialTheme.colorScheme.surface,
modifier = Modifier.padding(top = Spacing.lg)
) {
// Header with app branding
androidx.compose.foundation.layout.Column(
modifier = Modifier
.padding(Dimens.Medium),
text = stringResource(id = R.string.menu_navigation_drawer_header),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary,
.fillMaxWidth()
.padding(horizontal = Spacing.lg, vertical = Spacing.xl)
) {
androidx.compose.material3.Text(
text = stringResource(id = R.string.menu_navigation_drawer_header),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.primary,
)
androidx.compose.material3.Text(
text = "Your personal recipe collection",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = Spacing.xs)
)
}
androidx.compose.material3.HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant,
thickness = 1.dp,
modifier = Modifier.padding(horizontal = Spacing.lg)
)
drawerItems.forEach { item ->
NavigationDrawerItem(
name = item.getName(),
selected = item.isSelected(),
icon = item.icon,
onClick = { item.onClick(drawerState) },
)
// Navigation items
androidx.compose.foundation.layout.Column(
modifier = Modifier.padding(vertical = Spacing.md)
) {
drawerItems.forEach { item ->
NavigationDrawerItem(
name = item.getName(),
selected = item.isSelected(),
icon = item.icon,
onClick = { item.onClick(drawerState) },
)
}
}
}
}
@@ -62,10 +93,12 @@ private fun NavigationDrawerItem(
) {
androidx.compose.material3.NavigationDrawerItem(
modifier = modifier
.padding(horizontal = Dimens.Medium),
.padding(horizontal = Spacing.md),
label = {
Text(
text = name,
style = MaterialTheme.typography.bodyLarge,
color = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
)
},
selected = selected,
@@ -73,8 +106,18 @@ private fun NavigationDrawerItem(
Icon(
imageVector = icon,
contentDescription = null,
tint = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant
)
},
onClick = onClick,
colors = androidx.compose.material3.NavigationDrawerItemDefaults.colors(
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
unselectedContainerColor = androidx.compose.ui.graphics.Color.Transparent,
selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
selectedTextColor = MaterialTheme.colorScheme.onPrimaryContainer,
unselectedTextColor = MaterialTheme.colorScheme.onSurface
),
shape = androidx.compose.foundation.shape.RoundedCornerShape(BorderRadius.md)
)
}

View File

@@ -0,0 +1,80 @@
package com.atridad.mealient.ui.theme
import androidx.compose.ui.graphics.Color
// Light Theme Colors
val md_theme_light_primary = Color(0xFF6750A4)
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
val md_theme_light_primaryContainer = Color(0xFFEADDFF)
val md_theme_light_onPrimaryContainer = Color(0xFF21005D)
val md_theme_light_secondary = Color(0xFF625B71)
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
val md_theme_light_secondaryContainer = Color(0xFFE8DEF8)
val md_theme_light_onSecondaryContainer = Color(0xFF1D192B)
val md_theme_light_tertiary = Color(0xFF7D5260)
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
val md_theme_light_tertiaryContainer = Color(0xFFFFD8E4)
val md_theme_light_onTertiaryContainer = Color(0xFF31111D)
val md_theme_light_error = Color(0xFFBA1A1A)
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
val md_theme_light_onError = Color(0xFFFFFFFF)
val md_theme_light_onErrorContainer = Color(0xFF410002)
val md_theme_light_background = Color(0xFFFFFBFE)
val md_theme_light_onBackground = Color(0xFF1C1B1F)
val md_theme_light_surface = Color(0xFFFFFBFE)
val md_theme_light_onSurface = Color(0xFF1C1B1F)
val md_theme_light_surfaceVariant = Color(0xFFE7E0EC)
val md_theme_light_onSurfaceVariant = Color(0xFF49454F)
val md_theme_light_outline = Color(0xFF79747E)
val md_theme_light_inverseOnSurface = Color(0xFFF4EFF4)
val md_theme_light_inverseSurface = Color(0xFF313033)
val md_theme_light_inversePrimary = Color(0xFFD0BCFF)
val md_theme_light_shadow = Color(0xFF000000)
val md_theme_light_surfaceTint = Color(0xFF6750A4)
val md_theme_light_outlineVariant = Color(0xFFCAC4D0)
val md_theme_light_scrim = Color(0xFF000000)
// Dark Theme Colors
val md_theme_dark_primary = Color(0xFFD0BCFF)
val md_theme_dark_onPrimary = Color(0xFF381E72)
val md_theme_dark_primaryContainer = Color(0xFF4F378B)
val md_theme_dark_onPrimaryContainer = Color(0xFFEADDFF)
val md_theme_dark_secondary = Color(0xFFCCC2DC)
val md_theme_dark_onSecondary = Color(0xFF332D41)
val md_theme_dark_secondaryContainer = Color(0xFF4A4458)
val md_theme_dark_onSecondaryContainer = Color(0xFFE8DEF8)
val md_theme_dark_tertiary = Color(0xFFEFB8C8)
val md_theme_dark_onTertiary = Color(0xFF492532)
val md_theme_dark_tertiaryContainer = Color(0xFF633B48)
val md_theme_dark_onTertiaryContainer = Color(0xFFFFD8E4)
val md_theme_dark_error = Color(0xFFFFB4AB)
val md_theme_dark_errorContainer = Color(0xFF93000A)
val md_theme_dark_onError = Color(0xFF690005)
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
val md_theme_dark_background = Color(0xFF1C1B1F)
val md_theme_dark_onBackground = Color(0xFFE6E1E5)
val md_theme_dark_surface = Color(0xFF1C1B1F)
val md_theme_dark_onSurface = Color(0xFFE6E1E5)
val md_theme_dark_surfaceVariant = Color(0xFF49454F)
val md_theme_dark_onSurfaceVariant = Color(0xFFCAC4D0)
val md_theme_dark_outline = Color(0xFF938F99)
val md_theme_dark_inverseOnSurface = Color(0xFF1C1B1F)
val md_theme_dark_inverseSurface = Color(0xFFE6E1E5)
val md_theme_dark_inversePrimary = Color(0xFF6750A4)
val md_theme_dark_shadow = Color(0xFF000000)
val md_theme_dark_surfaceTint = Color(0xFFD0BCFF)
val md_theme_dark_outlineVariant = Color(0xFF49454F)
val md_theme_dark_scrim = Color(0xFF000000)
// Custom App Colors
val md_theme_light_food_primary = Color(0xFF2E7D32) // Green for food
val md_theme_light_food_secondary = Color(0xFFFF8F00) // Orange for recipes
val md_theme_light_food_tertiary = Color(0xFFD32F2F) // Red for cooking
val md_theme_light_food_surface = Color(0xFFF8F9FA)
val md_theme_light_food_surfaceVariant = Color(0xFFE8F5E8)
val md_theme_dark_food_primary = Color(0xFF4CAF50) // Green for food
val md_theme_dark_food_secondary = Color(0xFFFFB74D) // Orange for recipes
val md_theme_dark_food_tertiary = Color(0xFFEF5350) // Red for cooking
val md_theme_dark_food_surface = Color(0xFF1A1A1A)
val md_theme_dark_food_surfaceVariant = Color(0xFF2E2E2E)

View File

@@ -0,0 +1,83 @@
package com.atridad.mealient.ui.theme
import androidx.compose.ui.unit.dp
// Material 3 Spacing System
object Spacing {
// Base spacing unit is 4dp
val xs = 4.dp
val sm = 8.dp
val md = 16.dp
val lg = 24.dp
val xl = 32.dp
val xxl = 48.dp
val xxxl = 64.dp
}
// Material 3 Component Sizing
object ComponentSizing {
// Button heights
val buttonHeight = 40.dp
val buttonHeightLarge = 48.dp
val buttonHeightSmall = 32.dp
// Input field heights
val inputFieldHeight = 56.dp
val inputFieldHeightSmall = 48.dp
// Card dimensions
val cardElevation = 1.dp
val cardElevationHovered = 8.dp
val cardElevationPressed = 12.dp
// Icon sizes
val iconSize = 24.dp
val iconSizeSmall = 20.dp
val iconSizeLarge = 32.dp
// Avatar sizes
val avatarSize = 40.dp
val avatarSizeSmall = 32.dp
val avatarSizeLarge = 56.dp
// FAB sizes
val fabSize = 56.dp
val fabSizeSmall = 40.dp
val fabSizeLarge = 96.dp
}
// Material 3 Border Radius
object BorderRadius {
val xs = 4.dp
val sm = 8.dp
val md = 12.dp
val lg = 16.dp
val xl = 20.dp
val xxl = 28.dp
val full = 50.dp
}
// Material 3 Content Padding
object ContentPadding {
val xs = 4.dp
val sm = 8.dp
val md = 16.dp
val lg = 24.dp
val xl = 32.dp
val xxl = 48.dp
}
// Material 3 List Item Spacing
object ListSpacing {
val itemSpacing = 8.dp
val sectionSpacing = 16.dp
val listPadding = 16.dp
}
// Material 3 Screen Margins
object ScreenMargins {
val horizontal = 16.dp
val horizontalLarge = 24.dp
val vertical = 16.dp
val verticalLarge = 24.dp
}

View File

@@ -0,0 +1,144 @@
package com.atridad.mealient.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val LightColorScheme = lightColorScheme(
// Primary colors
primary = md_theme_light_primary,
onPrimary = md_theme_light_onPrimary,
primaryContainer = md_theme_light_primaryContainer,
onPrimaryContainer = md_theme_light_onPrimaryContainer,
// Secondary colors
secondary = md_theme_light_secondary,
onSecondary = md_theme_light_onSecondary,
secondaryContainer = md_theme_light_secondaryContainer,
onSecondaryContainer = md_theme_light_onSecondaryContainer,
// Tertiary colors
tertiary = md_theme_light_tertiary,
onTertiary = md_theme_light_onTertiary,
tertiaryContainer = md_theme_light_tertiaryContainer,
onTertiaryContainer = md_theme_light_onTertiaryContainer,
// Error colors
error = md_theme_light_error,
onError = md_theme_light_onError,
errorContainer = md_theme_light_errorContainer,
onErrorContainer = md_theme_light_onErrorContainer,
// Neutral colors
background = md_theme_light_background,
onBackground = md_theme_light_onBackground,
surface = md_theme_light_surface,
onSurface = md_theme_light_onSurface,
surfaceVariant = md_theme_light_surfaceVariant,
onSurfaceVariant = md_theme_light_onSurfaceVariant,
// Outline colors
outline = md_theme_light_outline,
outlineVariant = md_theme_light_outlineVariant,
// Inverse colors
inverseOnSurface = md_theme_light_inverseOnSurface,
inverseSurface = md_theme_light_inverseSurface,
inversePrimary = md_theme_light_inversePrimary,
// Surface colors
surfaceTint = md_theme_light_surfaceTint,
scrim = md_theme_light_scrim,
)
private val DarkColorScheme = darkColorScheme(
// Primary colors
primary = md_theme_dark_primary,
onPrimary = md_theme_dark_onPrimary,
primaryContainer = md_theme_dark_primaryContainer,
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
// Secondary colors
secondary = md_theme_dark_secondary,
onSecondary = md_theme_dark_onSecondary,
secondaryContainer = md_theme_dark_secondaryContainer,
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
// Tertiary colors
tertiary = md_theme_dark_tertiary,
onTertiary = md_theme_dark_onTertiary,
tertiaryContainer = md_theme_dark_tertiaryContainer,
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
// Error colors
error = md_theme_dark_error,
onError = md_theme_dark_onError,
errorContainer = md_theme_dark_errorContainer,
onErrorContainer = md_theme_dark_onErrorContainer,
// Neutral colors
background = md_theme_dark_background,
onBackground = md_theme_dark_onBackground,
surface = md_theme_dark_surface,
onSurface = md_theme_dark_onSurface,
surfaceVariant = md_theme_dark_surfaceVariant,
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
// Outline colors
outline = md_theme_dark_outline,
outlineVariant = md_theme_dark_outlineVariant,
// Inverse colors
inverseOnSurface = md_theme_dark_inverseOnSurface,
inverseSurface = md_theme_dark_inverseSurface,
inversePrimary = md_theme_dark_inversePrimary,
// Surface colors
surfaceTint = md_theme_dark_surfaceTint,
scrim = md_theme_dark_scrim,
)
@Composable
fun MealientTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
// Make status bar transparent so it blends with the background
window.statusBarColor = android.graphics.Color.TRANSPARENT
// Set light status bar icons for dark theme, dark icons for light theme
// This ensures proper contrast for the status bar icons
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View File

@@ -0,0 +1,125 @@
package com.atridad.mealient.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Material 3 Typography Scale
val Typography = Typography(
// Display styles - Large text for hero sections
displayLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp,
),
displayMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 45.sp,
lineHeight = 52.sp,
letterSpacing = 0.sp,
),
displaySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 36.sp,
lineHeight = 44.sp,
letterSpacing = 0.sp,
),
// Headline styles - Section titles
headlineLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 32.sp,
lineHeight = 40.sp,
letterSpacing = 0.sp,
),
headlineMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 28.sp,
lineHeight = 36.sp,
letterSpacing = 0.sp,
),
headlineSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 24.sp,
lineHeight = 32.sp,
letterSpacing = 0.sp,
),
// Title styles - Card titles, list headers
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp,
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.15.sp,
),
titleSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
// Body styles - Main content text
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp,
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp,
),
bodySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.4.sp,
),
// Label styles - Buttons, form fields, captions
labelLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
labelMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp,
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp,
),
)