Complete migration to Compose (#194)

* Migrate disclaimer screen to Compose

* Migrate base URL screen to Compose

* Migrate base URL screen to Compose

* Migrate authentication screen to Compose

* Initialize add recipe screen

* Remove unused resources

* Display add recipe operation result

* Add delete icon to ingredients and instructions

* Allow navigating between fields on add recipe

* Allow navigating between fields on authentication screen

* Allow to proceed from keyboard on base url screen

* Use material icons for recipe item

* Expose base URL as flow

* Initialize Compose navigation

* Allow sending logs again

* Allow to override navigation and top bar per screen

* Add additional logs

* Migrate share recipe screen to Compose

* Fix unit tests

* Restore recipe list tests

* Ensure authentication is shown after URL input

* Add autofill to authentication

* Complete first set up test

* Use image vector from Icons instead of drawable

* Add transition animations

* Fix logging host in Host header

* Do not fail test if login token is used
This commit is contained in:
Kirill Kamakin
2024-01-13 11:28:10 +01:00
committed by GitHub
parent 94f12820bc
commit de4df95a0e
107 changed files with 3294 additions and 2321 deletions

View File

@@ -1,20 +0,0 @@
package gq.kirmanak.mealient.ui
data class ActivityUiState(
val isAuthorized: Boolean = false,
val navigationVisible: Boolean = false,
val searchVisible: Boolean = false,
val checkedMenuItem: CheckableMenuItem? = null,
) {
val canShowLogin: Boolean get() = !isAuthorized
val canShowLogout: Boolean get() = isAuthorized
}
enum class CheckableMenuItem {
ShoppingLists,
RecipesList,
AddRecipe,
ChangeUrl,
Login
}

View File

@@ -1,12 +0,0 @@
package gq.kirmanak.mealient.ui
import kotlinx.coroutines.flow.StateFlow
interface ActivityUiStateController {
fun updateUiState(update: (ActivityUiState) -> ActivityUiState)
fun getUiState(): ActivityUiState
fun getUiStateFlow(): StateFlow<ActivityUiState>
}

View File

@@ -1,21 +0,0 @@
package gq.kirmanak.mealient.ui
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.getAndUpdate
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class ActivityUiStateControllerImpl @Inject constructor() : ActivityUiStateController {
private val uiState = MutableStateFlow(ActivityUiState())
override fun updateUiState(update: (ActivityUiState) -> ActivityUiState) {
uiState.getAndUpdate(update)
}
override fun getUiState(): ActivityUiState = uiState.value
override fun getUiStateFlow(): StateFlow<ActivityUiState> = uiState.asStateFlow()
}

View File

@@ -1,37 +0,0 @@
package gq.kirmanak.mealient.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.ComposeView
import androidx.fragment.app.Fragment
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject
@AndroidEntryPoint
abstract class BaseComposeFragment : Fragment() {
@Inject
lateinit var logger: Logger
final override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
logger.v { "onCreateView() called" }
return ComposeView(requireContext()).apply {
setContent {
AppTheme {
Screen()
}
}
}
}
@Composable
abstract fun Screen()
}

View File

@@ -30,8 +30,7 @@ sealed class OperationUiState<T> {
class Initial<T> : OperationUiState<T>() {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
return true
return javaClass == other?.javaClass
}
override fun hashCode(): Int {
@@ -42,8 +41,7 @@ sealed class OperationUiState<T> {
class Progress<T> : OperationUiState<T>() {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
return true
return javaClass == other?.javaClass
}
override fun hashCode(): Int {

View File

@@ -45,4 +45,5 @@ object Dimens {
val Medium = 16.dp
val Large = 24.dp
}

View File

@@ -11,9 +11,6 @@ import gq.kirmanak.mealient.ui.util.LoadingHelperFactoryImpl
@InstallIn(SingletonComponent::class)
internal interface UiModule {
@Binds
fun bindActivityUiStateController(impl: ActivityUiStateControllerImpl): ActivityUiStateController
@Binds
fun bindLoadingHelperFactory(impl: LoadingHelperFactoryImpl): LoadingHelperFactory
}

View File

@@ -0,0 +1,143 @@
package gq.kirmanak.mealient.ui.components
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.DrawerState
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import gq.kirmanak.mealient.ui.R
import kotlinx.coroutines.launch
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun BaseScreen(
topAppBar: @Composable () -> Unit = { },
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
content: @Composable (Modifier) -> Unit,
) {
Scaffold(
topBar = { topAppBar() },
snackbarHost = { SnackbarHost(snackbarHostState) },
) { paddingValues ->
content(
Modifier
.padding(paddingValues)
.consumeWindowInsets(paddingValues),
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun BaseScreenWithNavigation(
baseScreenState: BaseScreenState,
drawerState: DrawerState = rememberDrawerState(initialValue = DrawerValue.Closed),
topAppBar: @Composable () -> Unit = { NavigationTopAppBar(drawerState) },
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
content: @Composable (Modifier) -> Unit,
) {
ModalNavigationDrawer(
drawerContent = {
DrawerContent(
drawerState = drawerState,
drawerItems = baseScreenState.drawerItems,
)
},
drawerState = drawerState,
) {
Scaffold(
topBar = { topAppBar() },
snackbarHost = { SnackbarHost(snackbarHostState) },
) { paddingValues ->
content(
Modifier
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
)
}
}
}
class BaseScreenState internal constructor(
drawerItems: List<DrawerItem>,
) {
val drawerItems by mutableStateOf(drawerItems)
}
@Composable
fun rememberBaseScreenState(
drawerItems: List<DrawerItem>,
): BaseScreenState {
return remember {
BaseScreenState(
drawerItems = drawerItems,
)
}
}
@Composable
fun previewBaseScreenState(): BaseScreenState {
return rememberBaseScreenState(
drawerItems = emptyList(),
)
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun NavigationTopAppBar(
drawerState: DrawerState,
) {
TopAppBar(
title = { },
navigationIcon = {
OpenDrawerIconButton(
drawerState = drawerState,
)
},
)
}
@Composable
fun OpenDrawerIconButton(
drawerState: DrawerState,
) {
val coroutineScope = rememberCoroutineScope()
IconButton(
modifier = Modifier
.semantics { testTag = "open-drawer-button" },
onClick = {
coroutineScope.launch {
drawerState.open()
}
},
) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.view_toolbar_navigation_icon_content_description),
)
}
}

View File

@@ -0,0 +1,80 @@
package gq.kirmanak.mealient.ui.components
import androidx.compose.foundation.layout.padding
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.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import gq.kirmanak.mealient.ui.Dimens
import gq.kirmanak.mealient.ui.R
interface DrawerItem {
@Composable
fun getName(): String
val icon: ImageVector
@Composable
fun isSelected(): Boolean
val onClick: (DrawerState) -> Unit
}
@Composable
internal fun DrawerContent(
drawerState: DrawerState,
drawerItems: List<DrawerItem>,
) {
ModalDrawerSheet {
Text(
modifier = Modifier
.padding(Dimens.Medium),
text = stringResource(id = R.string.menu_navigation_drawer_header),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary,
)
drawerItems.forEach { item ->
NavigationDrawerItem(
name = item.getName(),
selected = item.isSelected(),
icon = item.icon,
onClick = { item.onClick(drawerState) },
)
}
}
}
@Composable
private fun NavigationDrawerItem(
name: String,
selected: Boolean,
icon: ImageVector,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
androidx.compose.material3.NavigationDrawerItem(
modifier = modifier
.padding(horizontal = Dimens.Medium),
label = {
Text(
text = name,
)
},
selected = selected,
icon = {
Icon(
imageVector = icon,
contentDescription = null,
)
},
onClick = onClick,
)
}

View File

@@ -0,0 +1,33 @@
package gq.kirmanak.mealient.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
@Composable
fun TopProgressIndicator(
isLoading: Boolean,
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit = {},
) {
Box(
modifier = modifier,
) {
if (isLoading) {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.TopCenter)
.semantics { testTag = "progress-indicator" },
)
}
content()
}
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name" translatable="false">Mealient</string>
<string name="menu_navigation_drawer_header" translatable="false">@string/app_name</string>
<string name="view_toolbar_navigation_icon_content_description">Open navigation drawer</string>
</resources>