diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ae658c3..d31c3c4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { diff --git a/app/src/main/java/com/atridad/mealient/data/storage/PreferencesStorage.kt b/app/src/main/java/com/atridad/mealient/data/storage/PreferencesStorage.kt index 4d89c36..2275e4f 100644 --- a/app/src/main/java/com/atridad/mealient/data/storage/PreferencesStorage.kt +++ b/app/src/main/java/com/atridad/mealient/data/storage/PreferencesStorage.kt @@ -11,6 +11,8 @@ interface PreferencesStorage { val lastExecutedMigrationVersionKey: Preferences.Key + val themeModeKey: Preferences.Key + suspend fun getValue(key: Preferences.Key): T? suspend fun requireValue(key: Preferences.Key): T diff --git a/app/src/main/java/com/atridad/mealient/data/storage/PreferencesStorageImpl.kt b/app/src/main/java/com/atridad/mealient/data/storage/PreferencesStorageImpl.kt index ffbb9dc..b6401a1 100644 --- a/app/src/main/java/com/atridad/mealient/data/storage/PreferencesStorageImpl.kt +++ b/app/src/main/java/com/atridad/mealient/data/storage/PreferencesStorageImpl.kt @@ -29,6 +29,9 @@ class PreferencesStorageImpl @Inject constructor( override val lastExecutedMigrationVersionKey: Preferences.Key = intPreferencesKey("lastExecutedMigrationVersion") + override val themeModeKey: Preferences.Key = + stringPreferencesKey("themeMode") + override suspend fun getValue(key: Preferences.Key): T? { val value = dataStore.data.first()[key] logger.v { "getValue() returned: $value for $key" } diff --git a/app/src/main/java/com/atridad/mealient/ui/NavGraphs.kt b/app/src/main/java/com/atridad/mealient/ui/NavGraphs.kt index b10c24d..91974f0 100644 --- a/app/src/main/java/com/atridad/mealient/ui/NavGraphs.kt +++ b/app/src/main/java/com/atridad/mealient/ui/NavGraphs.kt @@ -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( diff --git a/app/src/main/java/com/atridad/mealient/ui/activity/DrawerContent.kt b/app/src/main/java/com/atridad/mealient/ui/activity/DrawerContent.kt index 7c2e50a..cf21d45 100644 --- a/app/src/main/java/com/atridad/mealient/ui/activity/DrawerContent.kt +++ b/app/src/main/java/com/atridad/mealient/ui/activity/DrawerContent.kt @@ -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( diff --git a/app/src/main/java/com/atridad/mealient/ui/activity/MainActivity.kt b/app/src/main/java/com/atridad/mealient/ui/activity/MainActivity.kt index 5f99a72..ecbfee5 100644 --- a/app/src/main/java/com/atridad/mealient/ui/activity/MainActivity.kt +++ b/app/src/main/java/com/atridad/mealient/ui/activity/MainActivity.kt @@ -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() + @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) } } diff --git a/app/src/main/java/com/atridad/mealient/ui/activity/MealientApp.kt b/app/src/main/java/com/atridad/mealient/ui/activity/MealientApp.kt index cfd44bb..fb4bbf3 100644 --- a/app/src/main/java/com/atridad/mealient/ui/activity/MealientApp.kt +++ b/app/src/main/java/com/atridad/mealient/ui/activity/MealientApp.kt @@ -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) + ) + } } diff --git a/app/src/main/java/com/atridad/mealient/ui/add/AddRecipeScreen.kt b/app/src/main/java/com/atridad/mealient/ui/add/AddRecipeScreen.kt index b5a65cb..a40ade0 100644 --- a/app/src/main/java/com/atridad/mealient/ui/add/AddRecipeScreen.kt +++ b/app/src/main/java/com/atridad/mealient/ui/add/AddRecipeScreen.kt @@ -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 = {}, ) diff --git a/app/src/main/java/com/atridad/mealient/ui/baseurl/BaseURLScreen.kt b/app/src/main/java/com/atridad/mealient/ui/baseurl/BaseURLScreen.kt index 3ac95dc..c25194a 100644 --- a/app/src/main/java/com/atridad/mealient/ui/baseurl/BaseURLScreen.kt +++ b/app/src/main/java/com/atridad/mealient/ui/baseurl/BaseURLScreen.kt @@ -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 = {}, - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/atridad/mealient/ui/recipes/info/RecipeScreen.kt b/app/src/main/java/com/atridad/mealient/ui/recipes/info/RecipeScreen.kt index 9fe4525..5c6b9f3 100644 --- a/app/src/main/java/com/atridad/mealient/ui/recipes/info/RecipeScreen.kt +++ b/app/src/main/java/com/atridad/mealient/ui/recipes/info/RecipeScreen.kt @@ -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, diff --git a/app/src/main/java/com/atridad/mealient/ui/recipes/list/ConfirmDeleteDialog.kt b/app/src/main/java/com/atridad/mealient/ui/recipes/list/ConfirmDeleteDialog.kt index 9bf09f8..7ad14c7 100644 --- a/app/src/main/java/com/atridad/mealient/ui/recipes/list/ConfirmDeleteDialog.kt +++ b/app/src/main/java/com/atridad/mealient/ui/recipes/list/ConfirmDeleteDialog.kt @@ -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) ) } \ No newline at end of file diff --git a/app/src/main/java/com/atridad/mealient/ui/recipes/list/RecipeItem.kt b/app/src/main/java/com/atridad/mealient/ui/recipes/list/RecipeItem.kt index c7e6351..fc75ee7 100644 --- a/app/src/main/java/com/atridad/mealient/ui/recipes/list/RecipeItem.kt +++ b/app/src/main/java/com/atridad/mealient/ui/recipes/list/RecipeItem.kt @@ -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 = {}, diff --git a/app/src/main/java/com/atridad/mealient/ui/recipes/list/RecipesList.kt b/app/src/main/java/com/atridad/mealient/ui/recipes/list/RecipesList.kt index df86b85..ae232bb 100644 --- a/app/src/main/java/com/atridad/mealient/ui/recipes/list/RecipesList.kt +++ b/app/src/main/java/com/atridad/mealient/ui/recipes/list/RecipesList.kt @@ -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 = 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), - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/atridad/mealient/ui/recipes/list/RecipesListViewModel.kt b/app/src/main/java/com/atridad/mealient/ui/recipes/list/RecipesListViewModel.kt index 761896c..d4038b7 100644 --- a/app/src/main/java/com/atridad/mealient/ui/recipes/list/RecipesListViewModel.kt +++ b/app/src/main/java/com/atridad/mealient/ui/recipes/list/RecipesListViewModel.kt @@ -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 + + } diff --git a/app/src/main/java/com/atridad/mealient/ui/recipes/list/SearchTextField.kt b/app/src/main/java/com/atridad/mealient/ui/recipes/list/SearchTextField.kt index fd35737..37a1b14 100644 --- a/app/src/main/java/com/atridad/mealient/ui/recipes/list/SearchTextField.kt +++ b/app/src/main/java/com/atridad/mealient/ui/recipes/list/SearchTextField.kt @@ -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 = {}, diff --git a/app/src/main/java/com/atridad/mealient/ui/settings/SettingsScreen.kt b/app/src/main/java/com/atridad/mealient/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..122e043 --- /dev/null +++ b/app/src/main/java/com/atridad/mealient/ui/settings/SettingsScreen.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/com/atridad/mealient/ui/settings/SettingsScreenEvent.kt b/app/src/main/java/com/atridad/mealient/ui/settings/SettingsScreenEvent.kt new file mode 100644 index 0000000..05c37cb --- /dev/null +++ b/app/src/main/java/com/atridad/mealient/ui/settings/SettingsScreenEvent.kt @@ -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 +} diff --git a/app/src/main/java/com/atridad/mealient/ui/settings/SettingsScreenState.kt b/app/src/main/java/com/atridad/mealient/ui/settings/SettingsScreenState.kt new file mode 100644 index 0000000..6963fc5 --- /dev/null +++ b/app/src/main/java/com/atridad/mealient/ui/settings/SettingsScreenState.kt @@ -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, +} diff --git a/app/src/main/java/com/atridad/mealient/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/atridad/mealient/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..8ea2a54 --- /dev/null +++ b/app/src/main/java/com/atridad/mealient/ui/settings/SettingsViewModel.kt @@ -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 = _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 + ) + } + } + } +} diff --git a/app/src/main/res/values-de/plurals.xml b/app/src/main/res/values-de/plurals.xml deleted file mode 100644 index 8cad8f2..0000000 --- a/app/src/main/res/values-de/plurals.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - Okay (%d Sekunde) - Okay (%d Sekunden) - - diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml deleted file mode 100644 index 7e6c529..0000000 --- a/app/src/main/res/values-de/strings.xml +++ /dev/null @@ -1,83 +0,0 @@ - - - E-Mail oder Nutzername - Passwort - Server-URL - Anmeldung - Bild der gekochten Mahlzeit - Abmeldung - Laden… - Inhaltsstoffe - Anweisungen - 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. - URL darf nicht leer sein - Kann keine Verbindung herstellen, Adresse prüfen. - Unerwartete Antwort. Ist es Mealie? - URL-Format prüfen: %s - Weiter - Die Identität des Servers konnte nicht überprüft werden - Vertrauen Sie diesem Zertifikat?\n\nInformationen zum Zertifikat:\nAussteller: %1$s\nBetreff: %2$s\nGültig von: %3$s\nGültig bis: %4$s - Vertrauen - Nein - Anmeldung - Okay - Schritt: %d - E-Mail kann nicht leer sein - Das Passwort darf nicht leer sein - E-Mail oder Passwort sind falsch. - Es ist ein Fehler aufgetreten, bitte versuchen Sie es erneut. - Name des Rezepts - Beschreibung - Rezept hinzufügen - Rezepte - Ausbeute des Rezepts - Rezept speichern - Neuer Schritt - Neue Zutat - Öffentliches Rezept - Kommentare deaktivieren - Zutat - Beschreibung der Schritte - Rezeptname darf nicht leer sein - Etwas ist schief gelaufen - Rezept erfolgreich gespeichert - Klar - Beispiel: demo.mealie.io - Beispiel: changeme@example.com - Beispiel: MyPassword - Zuletzt geladene Seite - Ladefehler: %1$s. - Laden fehlgeschlagen. - unbefugt - unerwartete Antwort - keine Verbindung - Favoritenstatusaktualisierung fehlgeschlagen - Rezeptentfernung fehlgeschlagen - Rezept löschen - Sind Sie sicher, dass Sie %1$slöschen möchten? Dies kann nicht rückgängig gemacht werden. - Bestätigen Sie - Abbrechen - URL ändern - Rezepte suchen - Navigationsschublade öffnen - Keine Rezepte - Rezept erfolgreich gespeichert. - Etwas ist schief gelaufen. - Indikator für den Fortschritt - Artikel ist Favorit - Artikel ist nicht beliebt - Rezept löschen - %1$s zu den Favoriten hinzugefügt - %1$s aus den Favoriten entfernt - Einkaufslisten - E-Mail Protokolle - Mealient Protokolle - 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. - Sende sensible Daten - Wählen Sie eine Sendemethode - Abbrechen - Abmelden läuft - Sind Sie sicher, dass Sie sich abmelden möchten? - Abmelden - Abbrechen - diff --git a/app/src/main/res/values-es/plurals.xml b/app/src/main/res/values-es/plurals.xml deleted file mode 100644 index f247bfa..0000000 --- a/app/src/main/res/values-es/plurals.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - Bien, (%d segundo) - Bien, (%d segundos) - - diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml deleted file mode 100644 index b0fb872..0000000 --- a/app/src/main/res/values-es/strings.xml +++ /dev/null @@ -1,83 +0,0 @@ - - - Email o nombre de usuario - Contraseña - URL del servidor - Iniciar sesión - Foto de la comida cocinada - Cerrar sesión - Cargando… - Ingredientes - Instrucciones - 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. - La URL no puede estar vacía - No se puede conectar, verifique la dirección. - Respuesta inesperada. ¿Es Mealie? - Comprobar el formato de URL: %s - Continuar - No se ha podido verificar la identidad del servidor - ¿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 - Confíe en - No - Iniciar sesión - Aceptar - Paso: %d - El correo electrónico no puede estar vacío - La contraseña no puede estar vacía - Correo electrónico o contraseña incorrectos. - Algo salió mal, por favor vuelve a intentarlo. - Nombre de la receta - Descripción - Agregar receta - Recetas - Porciones - Guardar receta - Nuevo paso - Nuevo ingrediente - Receta pública - Desactivar comentarios - Ingrediente - Descripción del paso - El nombre de la receta no puede estar vacío - Algo salió mal - Receta guardada con éxito - Limpiar - Ejemplo: demo.mealie.io - Ejemplo: changeme@example.com - Ejemplo: MyPassword - Última página cargada - Error al cargar: %1$s. - La carga falló. - no autorizado - respuesta inesperada - sin conexión - Error al actualizar el estado de favorito - Error al eliminar la receta - Eliminar receta - ¿Está seguro que desea eliminar %1$s? Esto no se puede deshacer. - Confirmar - Cancelar - Cambiar URL - Buscar recetas - Abrir cajón de navegación - Sin recetas - Receta guardada exitosamente. - Algo salió mal. - Indicador de progreso - El artículo es favorito - El artículo no es favorito - Eliminar receta - Añadido %1$s a favoritos - Eliminado %1$s de favoritos - Listas de la compra - Registros de correo electrónico - Registros mealientes - 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. - Envío de datos sensibles - Elija cómo enviar - Cancelar - Cerrar sesión - ¿Seguro que quieres desconectarte? - Cerrar sesión - Cancelar - diff --git a/app/src/main/res/values-fr/plurals.xml b/app/src/main/res/values-fr/plurals.xml deleted file mode 100644 index 40097b0..0000000 --- a/app/src/main/res/values-fr/plurals.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - Ok (%d seconde) - Ok (%d secondes) - - diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml deleted file mode 100644 index 8bf4fa3..0000000 --- a/app/src/main/res/values-fr/strings.xml +++ /dev/null @@ -1,83 +0,0 @@ - - - Email ou nom d\'utilisateur - Mot de passe - URL du serveur - Connexion - Photo du repas cuisiné - Déconnexion - Chargement de… - Ingrédients - Instructions - 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. - L\'URL ne peut pas être vide - Impossible de se connecter, vérifier l\'adresse. - Réponse inattendue. Est-ce Mealie ? - Vérifier le format de l\'URL : %s - Procéder - L\'identité du serveur n\'a pas pu être vérifiée - 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 - Confiance - Non - Connexion - D\'accord - Étape : %d - L\'e-mail ne peut pas être vide - Le mot de passe ne peut pas être vide - L\'e-mail ou le mot de passe est incorrect. - Un problème s\'est produit, veuillez réessayer. - Nom de la recette - Description - Ajouter une recette - Recettes - Rendement de la recette - Enregistrer la recette - Nouvelle étape - Nouvel ingrédient - Recette publique - Désactiver les commentaires - Ingrédient - Description des étapes - Le nom de la recette ne peut pas être vide - Quelque chose n\'a pas fonctionné - Sauvegarde réussie de la recette - Clair - Exemple : demo.mealie.io - Exemple : changeme@example.com - Exemple : MyPassword - Dernière page chargée - Erreur de chargement : %1$s. - Le chargement a échoué. - non autorisé - réponse inattendue - pas de connexion - La mise à jour du statut de favori a échoué - Échec de la suppression de la recette - Supprimer la recette - Êtes-vous sûr de vouloir supprimer %1$s? Cette opération ne peut être annulée. - Confirmer - Annuler - Modifier l\'URL - Rechercher des recettes - Ouvrir le tiroir de navigation - Pas de recettes - La recette a été enregistrée avec succès. - Quelque chose n\'a pas fonctionné. - Indicateur de progrès - L\'article est le préféré - L\'article n\'est pas favori - Supprimer la recette - Ajout de %1$s aux favoris - Suppression de %1$s des favoris - Listes de courses - Journaux des courriels - Journaux de la maltraitance - 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. - Envoi de données sensibles - Choisir le mode d\'envoi - Annuler - Déconnexion - Êtes-vous sûr de vouloir vous déconnecter ? - Déconnexion - Annuler - diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index a047150..17ff804 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -1,9 +1,19 @@ - + + \ No newline at end of file diff --git a/app/src/main/res/values-nl/plurals.xml b/app/src/main/res/values-nl/plurals.xml deleted file mode 100644 index 50e416f..0000000 --- a/app/src/main/res/values-nl/plurals.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - Oké (%d seconde) - Oké (%d seconden) - - diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml deleted file mode 100644 index 06f98f4..0000000 --- a/app/src/main/res/values-nl/strings.xml +++ /dev/null @@ -1,83 +0,0 @@ - - - E-mail of gebruikersnaam - Wachtwoord - Server URL - Inloggen - Foto van de bereide maaltijd - Afmelden - Laden… - Ingrediënten - Instructies - 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. - URL kan niet leeg zijn - Kan geen verbinding maken, controleer adres. - Onverwachte reactie. Is het Mealie? - Controleer URL-indeling: %s - Ga verder - De identiteit van de server kon niet worden geverifieerd - Vertrouwt u dit certificaat?\n\nCertificaatinformatie:\nUitgevende instelling: %1$s\nOnderwerp: %2$s\nGeldig vanaf: %3$s\nGeldig tot: %4$s - Vertrouwen - Geen - Inloggen - Oké - Stap: %d - E-mail kan niet leeg zijn - Wachtwoord kan niet leeg zijn - E-mail of wachtwoord is onjuist. - Er is iets misgegaan, probeer het opnieuw. - Naam recept - Beschrijving - Recept toevoegen - Recepten - Recept opbrengst - Recept opslaan - Nieuwe stap - Nieuw ingrediënt - Publiek recept - Opmerkingen uitschakelen - Ingrediënt - Stapbeschrijving - Receptnaam kan niet leeg zijn - Er ging iets mis - Recept succesvol opgeslagen - Duidelijk - Voorbeeld: demo.mealie.io - Voorbeeld: changeme@example.com - Voorbeeld: MyPassword - Laatste pagina geladen - Fout bij laden: %1$s. - Laden mislukt. - onbevoegd - onverwachte reactie - geen verbinding - Favoriete statusupdate mislukt - Verwijderen van recept mislukt - Recept verwijderen - Weet je zeker dat je %1$swilt verwijderen? Dit kan niet ongedaan worden gemaakt. - Bevestig - Annuleren - URL wijzigen - Recepten zoeken - Open de navigatielade - Geen recepten - Recept succesvol opgeslagen. - Er ging iets mis. - Voortgangsindicator - Item is favoriet - Item is niet favoriet - Recept verwijderen - %1$s toegevoegd aan favorieten - Verwijderde %1$s uit favorieten - Boodschappenlijstjes - Logboeken e-mail - Mealient logs - 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. - Gevoelige gegevens verzenden - Kies hoe te verzenden - Annuleren - Afmelden - Weet je zeker dat je jezelf wilt afmelden? - Afmelden - Annuleren - diff --git a/app/src/main/res/values-pt/plurals.xml b/app/src/main/res/values-pt/plurals.xml deleted file mode 100644 index 0eec39c..0000000 --- a/app/src/main/res/values-pt/plurals.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - Ok (%d segundo) - Ok (%d segundos) - - diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml deleted file mode 100644 index 391b944..0000000 --- a/app/src/main/res/values-pt/strings.xml +++ /dev/null @@ -1,83 +0,0 @@ - - - E-mail ou nome de utilizador - Palavra-passe - URL do servidor - Iniciar sessão - Fotografia da refeição cozinhada - Terminar sessão - Carregando… - Ingredientes - Instruções - 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. - O URL não pode estar vazio - Não é possível estabelecer ligação, verificar endereço. - Resposta inesperada. É a Mealie? - Verificar o formato do URL: %s - Prosseguir - A identidade do servidor não pôde ser verificada - 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 - Confiança - Não - Iniciar sessão - Está bem - Passo: %d - O correio eletrónico não pode estar vazio - A palavra-passe não pode estar vazia - O e-mail ou a palavra-passe estão incorrectos. - Algo correu mal, por favor tente novamente. - Nome da receita - Descrição - Adicionar receita - Receitas - Rendimento da receita - Guardar receita - Nova etapa - Novo ingrediente - Receita pública - Desativar comentários - Ingrediente - Descrição das etapas - O nome da receita não pode estar vazio - Algo correu mal - Receita guardada com sucesso - Limpo - Exemplo: demo.mealie.io - Exemplo: changeme@example.com - Exemplo: MyPassword - Última página carregada - Erro de carregamento: %1$s. - O carregamento falhou. - não autorizado - resposta inesperada - sem ligação - Falha na atualização do estado dos favoritos - Falha na remoção da receita - Eliminar receita - Tem a certeza de que pretende apagar %1$s? Isto não pode ser anulado. - Confirmar - Cancelar - Alterar URL - Pesquisar receitas - Abrir a gaveta de navegação - Sem receitas - Receita guardada com sucesso. - Alguma coisa correu mal. - Indicador de progresso - O item é favorito - O item não é favorito - Eliminar receita - Adicionado %1$s aos favoritos - Removido %1$s dos favoritos - Listas de compras - Registos de correio eletrónico - Registos de refeições - 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. - Envio de dados sensíveis - Escolher como enviar - Cancelar - Terminar a sessão - Tem a certeza de que pretende terminar a sessão? - Terminar sessão - Cancelar - diff --git a/app/src/main/res/values-ru/plurals.xml b/app/src/main/res/values-ru/plurals.xml deleted file mode 100644 index 84ab020..0000000 --- a/app/src/main/res/values-ru/plurals.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - Хорошо (%d секунда) - Хорошо (%d секунды) - Хорошо (%d секунд) - Хорошо (%d секунд) - - diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml deleted file mode 100644 index 1b1644a..0000000 --- a/app/src/main/res/values-ru/strings.xml +++ /dev/null @@ -1,83 +0,0 @@ - - - Email или username - Пароль - URL сервера - Войти - Изображение готового блюда - Выйти - Загрузка - Ингредиенты - Инструкции - Этот проект разрабатывается независимо от основного проекта Meale. Он не связан с разработчиками Mealie. О любых проблемах следует писать в репозиторий Mealient, НЕ в репозиторий Mealie. - URL не может быть пустым - Ошибка подключения, проверьте адрес. - Неожиданный ответ. Это Mealie? - Проверьте формат URL: %s - Продолжить - Не удалось проверить подлинность сервера - Доверяете ли вы этому сертификату?\n\nИнформация о сертификате:\nIssuer: %1$s\nSubject: %2$s\nДействителен с: %3$s\nДействителен до: %4$s - Доверять - Нет - Войти - Хорошо - Шаг: %d - E-mail не может быть пустым - Пароль не может быть пустым - E-mail или пароль не подходит. - Что-то пошло не так, попробуйте еще раз. - Название рецепта - Описание - Добавить рецепт - Рецепты - Количество порций - Сохранить рецепт - Добавить шаг - Добавить ингредиент - Публичный рецепт - Отключить комментарии - Ингредиент - Описание шага - Имя рецепта не может быть пустым - Что-то пошло не так - Рецепт сохранен успешно - Очистить - Пример: demo.mealie.io - Пример: changeme@example.com - Пример: MyPassword - Последняя страница - Ошибка загрузки: %1$s. - Ошибка загрузки. - неавторизован - неожиданный ответ - нет соединения - Не удалось обновить статус избранного - Не удалось удалить рецепт - Удалить рецепт - Вы уверены, что хотите удалить %1$s? Удаление необратимо. - Подтвердить - Отмена - Сменить URL - Найти рецепты - Открыть меню навигации - Нет рецептов - Рецепт успешно сохранен. - Что-то пошло не так. - Индикатор прогресса - Добавлен в избранное - Не добавлен в избранное - Удалить рецепт - %1$s добавлено в избранное - %1$s удалено из избранного - Списки покупок - Журналы электронной почты - Бревна для меалиентов - В журналах содержатся конфиденциальные данные, такие как API-токен, списки покупок и рецепты. API-токены могут быть отозваны с помощью веб-клиента. Файл можно просматривать и редактировать, если отправить его самому себе. - Отправка конфиденциальных данных - Выберите способ отправки - Отмена - Выход из системы - Вы уверены, что хотите выйти из системы? - Выйти из системы - Отмена - diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index d9d0c04..4d6c704 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,10 +1,10 @@ - diff --git a/build-logic/convention/bin/main/AndroidApplicationComposeConventionPlugin.kt b/build-logic/convention/bin/main/AndroidApplicationComposeConventionPlugin.kt new file mode 100644 index 0000000..68453fc --- /dev/null +++ b/build-logic/convention/bin/main/AndroidApplicationComposeConventionPlugin.kt @@ -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 { + + override fun apply(target: Project) { + with(target) { + pluginManager.apply("com.android.application") + pluginManager.apply("org.jetbrains.kotlin.plugin.compose") + + extensions.configure { + configureAndroidCompose(this) + } + } + } +} \ No newline at end of file diff --git a/build-logic/convention/bin/main/AndroidApplicationConventionPlugin.kt b/build-logic/convention/bin/main/AndroidApplicationConventionPlugin.kt new file mode 100644 index 0000000..8608963 --- /dev/null +++ b/build-logic/convention/bin/main/AndroidApplicationConventionPlugin.kt @@ -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 { + + 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 { + configureKotlinAndroid(this) + defaultConfig.targetSdk = Versions.TARGET_SDK_VERSION + } + } + } +} \ No newline at end of file diff --git a/build-logic/convention/bin/main/AndroidLibraryComposeConventionPlugin.kt b/build-logic/convention/bin/main/AndroidLibraryComposeConventionPlugin.kt new file mode 100644 index 0000000..f19976f --- /dev/null +++ b/build-logic/convention/bin/main/AndroidLibraryComposeConventionPlugin.kt @@ -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 { + + override fun apply(target: Project) { + with(target) { + pluginManager.apply("org.jetbrains.kotlin.plugin.compose") + + extensions.configure { + configureAndroidCompose(this) + } + } + } +} \ No newline at end of file diff --git a/build-logic/convention/bin/main/AndroidLibraryConventionPlugin.kt b/build-logic/convention/bin/main/AndroidLibraryConventionPlugin.kt new file mode 100644 index 0000000..804ea32 --- /dev/null +++ b/build-logic/convention/bin/main/AndroidLibraryConventionPlugin.kt @@ -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 { + 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 { + configureKotlinAndroid(this) + defaultConfig.targetSdk = Versions.TARGET_SDK_VERSION + } + } + } +} \ No newline at end of file diff --git a/build-logic/convention/bin/main/com/atridad/mealient/AndroidCompose.kt b/build-logic/convention/bin/main/com/atridad/mealient/AndroidCompose.kt new file mode 100644 index 0000000..0ea8fc9 --- /dev/null +++ b/build-logic/convention/bin/main/com/atridad/mealient/AndroidCompose.kt @@ -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")) + } + } +} diff --git a/build-logic/convention/bin/main/com/atridad/mealient/Extensions.kt b/build-logic/convention/bin/main/com/atridad/mealient/Extensions.kt new file mode 100644 index 0000000..316f1f8 --- /dev/null +++ b/build-logic/convention/bin/main/com/atridad/mealient/Extensions.kt @@ -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): Unit = + (this as ExtensionAware).extensions.configure("kotlin", configure) + +internal fun KotlinAndroidProjectExtension.sourceSets(configure: Action>): Unit = + (this as ExtensionAware).extensions.configure("sourceSets", configure) + +internal fun Project.library(name: String): Provider { + return libs.findLibrary(name).get() +} + diff --git a/build-logic/convention/bin/main/com/atridad/mealient/KotlinAndroid.kt b/build-logic/convention/bin/main/com/atridad/mealient/KotlinAndroid.kt new file mode 100644 index 0000000..50d7fbd --- /dev/null +++ b/build-logic/convention/bin/main/com/atridad/mealient/KotlinAndroid.kt @@ -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) + } +} diff --git a/build-logic/convention/bin/main/com/atridad/mealient/Versions.kt b/build-logic/convention/bin/main/com/atridad/mealient/Versions.kt new file mode 100644 index 0000000..e1d98b2 --- /dev/null +++ b/build-logic/convention/bin/main/com/atridad/mealient/Versions.kt @@ -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 = 34 +} + +val Project.libs: VersionCatalog + get() = extensions.getByType().named("libs") diff --git a/datasource/src/main/kotlin/com/atridad/mealient/datasource/models/GetRecipeResponse.kt b/datasource/src/main/kotlin/com/atridad/mealient/datasource/models/GetRecipeResponse.kt index f6cf009..8a407a5 100644 --- a/datasource/src/main/kotlin/com/atridad/mealient/datasource/models/GetRecipeResponse.kt +++ b/datasource/src/main/kotlin/com/atridad/mealient/datasource/models/GetRecipeResponse.kt @@ -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 diff --git a/features/shopping_lists/src/main/kotlin/com/atridad/mealient/shopping_lists/ui/details/ShoppingListScreen.kt b/features/shopping_lists/src/main/kotlin/com/atridad/mealient/shopping_lists/ui/details/ShoppingListScreen.kt index 240e7d2..2a1dbc9 100644 --- a/features/shopping_lists/src/main/kotlin/com/atridad/mealient/shopping_lists/ui/details/ShoppingListScreen.kt +++ b/features/shopping_lists/src/main/kotlin/com/atridad/mealient/shopping_lists/ui/details/ShoppingListScreen.kt @@ -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, @@ -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, ) } } + } } }, diff --git a/features/shopping_lists/src/main/kotlin/com/atridad/mealient/shopping_lists/ui/list/ShoppingListsScreen.kt b/features/shopping_lists/src/main/kotlin/com/atridad/mealient/shopping_lists/ui/list/ShoppingListsScreen.kt index 9555029..5a884d5 100644 --- a/features/shopping_lists/src/main/kotlin/com/atridad/mealient/shopping_lists/ui/list/ShoppingListsScreen.kt +++ b/features/shopping_lists/src/main/kotlin/com/atridad/mealient/shopping_lists/ui/list/ShoppingListsScreen.kt @@ -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 = {} - ) - } -} - diff --git a/features/user_managment/src/main/kotlin/com/mealient/user_management/ui/profile/UserProfileScreen.kt b/features/user_managment/src/main/kotlin/com/mealient/user_management/ui/profile/UserProfileScreen.kt index 554b369..c69dabd 100644 --- a/features/user_managment/src/main/kotlin/com/mealient/user_management/ui/profile/UserProfileScreen.kt +++ b/features/user_managment/src/main/kotlin/com/mealient/user_management/ui/profile/UserProfileScreen.kt @@ -77,8 +77,7 @@ fun UserProfileScreen( UserProfileContent( state = state, onEvent = viewModel::onEvent, - onSelectImage = { imagePickerLauncher.launch("image/*") }, - onNavigateBack = { navigator.navigateUp() } + onSelectImage = { imagePickerLauncher.launch("image/*") } ) } @@ -88,53 +87,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) ) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b272b28..57c6909 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [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" # https://dagger.dev/hilt/gradle-setup diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index eb1a55b..20cb023 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionSha256Sum=f397b287023acdba1e9f6fc5ea72d22dd63669d59ed4a289a29b1a76eee151c6 -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/ui/src/main/kotlin/com/atridad/mealient/ui/components/DrawerContent.kt b/ui/src/main/kotlin/com/atridad/mealient/ui/components/DrawerContent.kt index 98c1e14..6ffafea 100644 --- a/ui/src/main/kotlin/com/atridad/mealient/ui/components/DrawerContent.kt +++ b/ui/src/main/kotlin/com/atridad/mealient/ui/components/DrawerContent.kt @@ -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, ) { - 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) ) } \ No newline at end of file diff --git a/ui/src/main/kotlin/com/atridad/mealient/ui/theme/Color.kt b/ui/src/main/kotlin/com/atridad/mealient/ui/theme/Color.kt new file mode 100644 index 0000000..37525b5 --- /dev/null +++ b/ui/src/main/kotlin/com/atridad/mealient/ui/theme/Color.kt @@ -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) diff --git a/ui/src/main/kotlin/com/atridad/mealient/ui/theme/Dimensions.kt b/ui/src/main/kotlin/com/atridad/mealient/ui/theme/Dimensions.kt new file mode 100644 index 0000000..85089f2 --- /dev/null +++ b/ui/src/main/kotlin/com/atridad/mealient/ui/theme/Dimensions.kt @@ -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 +} diff --git a/ui/src/main/kotlin/com/atridad/mealient/ui/theme/Theme.kt b/ui/src/main/kotlin/com/atridad/mealient/ui/theme/Theme.kt new file mode 100644 index 0000000..0e51ec7 --- /dev/null +++ b/ui/src/main/kotlin/com/atridad/mealient/ui/theme/Theme.kt @@ -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 + ) +} diff --git a/ui/src/main/kotlin/com/atridad/mealient/ui/theme/Type.kt b/ui/src/main/kotlin/com/atridad/mealient/ui/theme/Type.kt new file mode 100644 index 0000000..a15265c --- /dev/null +++ b/ui/src/main/kotlin/com/atridad/mealient/ui/theme/Type.kt @@ -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, + ), +)