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

@@ -77,6 +77,10 @@ android {
}
}
ksp {
arg("compose-destinations.generateNavGraphs", "false")
}
dependencies {
implementation(project(":architecture"))
@@ -112,6 +116,8 @@ dependencies {
implementation(libs.androidx.shareTarget)
implementation(libs.androidx.compose.materialIconsExtended)
implementation(libs.google.dagger.hiltAndroid)
kapt(libs.google.dagger.hiltCompiler)
kaptTest(libs.google.dagger.hiltAndroidCompiler)
@@ -132,6 +138,10 @@ dependencies {
implementation(libs.coil)
implementation(libs.coil.compose)
implementation(libs.androidx.compose.animation)
implementation(libs.androidx.hilt.navigationCompose)
testImplementation(libs.junit)
implementation(libs.jetbrains.kotlinx.coroutinesAndroid)

View File

@@ -1,9 +1,11 @@
package gq.kirmanak.mealient
import dagger.hilt.android.testing.HiltAndroidTest
import gq.kirmanak.mealient.screen.AuthenticationScreen
import gq.kirmanak.mealient.screen.BaseUrlScreen
import gq.kirmanak.mealient.screen.DisclaimerScreen
import gq.kirmanak.mealient.screen.RecipesListScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.kakao.common.utilities.getResourceString
import org.junit.Before
import org.junit.Test
@@ -13,63 +15,137 @@ class FirstSetUpTest : BaseTestCase() {
@Before
fun dispatchUrls() {
mockWebServer.dispatch { url, _ ->
if (url == "/api/app/about") versionV1Response else notFoundResponse
mockWebServer.dispatch { request ->
when (request.path) {
"/api/app/about" -> versionV1Response
"/api/auth/token" -> {
if (request.body == expectedLoginRequest) {
loginTokenResponse
} else {
notFoundResponse
}
}
"/api/users/api-tokens" -> {
if (request.authorization == expectedApiTokenAuthorizationHeader) {
apiTokenResponse
} else {
notFoundResponse
}
}
"/api/recipes?page=1&perPage=150" -> {
if (request.authorization == expectedAuthorizationHeader ||
request.authorization == expectedApiTokenAuthorizationHeader
) {
recipeSummariesResponse
} else {
notFoundResponse
}
}
else -> notFoundResponse
}
}
}
@Test
fun test() = run {
step("Ensure button is disabled") {
DisclaimerScreen {
step("Disclaimer screen with disabled button") {
onComposeScreen<DisclaimerScreen>(mainActivityRule) {
okayButton {
isVisible()
isDisabled()
hasAnyText()
assertIsNotEnabled()
}
okayButtonText {
assertTextContains(getResourceString(R.string.fragment_disclaimer_button_okay))
}
disclaimerText {
isVisible()
hasText(R.string.fragment_disclaimer_main_text)
assertTextEquals(getResourceString(R.string.fragment_disclaimer_main_text))
}
}
}
step("Close disclaimer screen") {
DisclaimerScreen {
onComposeScreen<DisclaimerScreen>(mainActivityRule) {
okayButtonText {
assertTextEquals(getResourceString(R.string.fragment_disclaimer_button_okay))
}
okayButton {
isVisible()
isEnabled()
hasText(R.string.fragment_disclaimer_button_okay)
click()
assertIsEnabled()
performClick()
}
}
}
step("Enter mock server address and click proceed") {
BaseUrlScreen {
onComposeScreen<BaseUrlScreen>(mainActivityRule) {
progressBar {
isGone()
assertDoesNotExist()
}
urlInput {
isVisible()
edit.replaceText(mockWebServer.url("/").toString())
hasHint(R.string.fragment_authentication_input_hint_url)
performTextInput(mockWebServer.url("/").toString())
}
urlInputLabel {
assertTextEquals(getResourceString(R.string.fragment_authentication_input_hint_url))
}
proceedButtonText {
assertTextEquals(getResourceString(R.string.fragment_base_url_save))
}
proceedButton {
isVisible()
isEnabled()
hasText(R.string.fragment_base_url_save)
click()
assertIsEnabled()
performClick()
}
}
}
step("Check that empty list of recipes is shown") {
RecipesListScreen(mainActivityRule).apply {
errorText {
step("Check that authentication is shown") {
onComposeScreen<AuthenticationScreen>(mainActivityRule) {
emailInput {
assertIsDisplayed()
}
passwordInput {
assertIsDisplayed()
}
loginButton {
assertIsDisplayed()
}
}
}
step("Enter credentials and click proceed") {
onComposeScreen<AuthenticationScreen>(mainActivityRule) {
emailInput {
performTextInput("test@test.test")
}
passwordInput {
performTextInput("password")
}
loginButton {
performClick()
}
}
}
step("Check that empty recipes list is shown") {
onComposeScreen<RecipesListScreen>(mainActivityRule) {
emptyListErrorText {
assertTextEquals(getResourceString(R.string.fragment_recipes_list_no_recipes))
}
openDrawerButton {
assertIsDisplayed()
}
searchRecipesField {
assertIsDisplayed()
assertTextEquals(getResourceString(R.string.fragment_recipes_load_failure_toast_no_reason))
}
}
}

View File

@@ -0,0 +1,62 @@
package gq.kirmanak.mealient
import android.util.Log
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
val versionV1Response = MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody("""{"production":true,"version":"v1.0.0beta-5","demoStatus":false,"allowSignup":true}""")
val notFoundResponse = MockResponse()
.setResponseCode(404)
.setHeader("Content-Type", "application/json")
.setBody("""{"detail":"Not found"}"""")
val expectedLoginRequest = """
username=test%40test.test&password=password
""".trimIndent()
val loginTokenResponse = MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody("""{"access_token":"login-token"}""")
val apiTokenResponse = MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody("""{"token":"api-token"}""")
val recipeSummariesResponse = MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody("""{"items":[]}""")
val expectedApiTokenAuthorizationHeader = "Bearer login-token"
val expectedAuthorizationHeader = "Bearer api-token"
data class RequestToDispatch(
val path: String,
val body: String,
val authorization: String?,
) {
constructor(recordedRequest: RecordedRequest) : this(
path = recordedRequest.path.orEmpty(),
body = recordedRequest.body.readUtf8(),
authorization = recordedRequest.getHeader("Authorization")
)
}
fun MockWebServer.dispatch(block: (RequestToDispatch) -> MockResponse) {
dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val requestToDispatch = RequestToDispatch(request)
Log.d("MockWebServer", "request = $requestToDispatch")
return block(requestToDispatch)
}
}
}

View File

@@ -1,24 +0,0 @@
package gq.kirmanak.mealient
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
val versionV1Response = MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody("""{"production":true,"version":"v1.0.0beta-5","demoStatus":false,"allowSignup":true}""")
val notFoundResponse = MockResponse()
.setResponseCode(404)
.setHeader("Content-Type", "application/json")
.setBody("""{"detail":"Not found"}"""")
fun MockWebServer.dispatch(block: (String, RecordedRequest) -> MockResponse) {
dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
return block(request.path.orEmpty(), request)
}
}
}

View File

@@ -0,0 +1,20 @@
package gq.kirmanak.mealient.screen
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.KNode
class AuthenticationScreen(
semanticsProvider: SemanticsNodeInteractionsProvider,
) : ComposeScreen<AuthenticationScreen>(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag("authentication-screen") },
) {
val emailInput = child<KNode> { hasTestTag("email-input") }
val passwordInput = child<KNode> { hasTestTag("password-input") }
val loginButton = child<KNode> { hasTestTag("login-button") }
}

View File

@@ -1,19 +1,25 @@
package gq.kirmanak.mealient.screen
import com.kaspersky.kaspresso.screens.KScreen
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.ui.baseurl.BaseURLFragment
import io.github.kakaocup.kakao.edit.KTextInputLayout
import io.github.kakaocup.kakao.progress.KProgressBar
import io.github.kakaocup.kakao.text.KButton
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.KNode
object BaseUrlScreen : KScreen<BaseUrlScreen>() {
override val layoutId = R.layout.fragment_base_url
override val viewClass = BaseURLFragment::class.java
class BaseUrlScreen(
semanticsProvider: SemanticsNodeInteractionsProvider,
) : ComposeScreen<BaseUrlScreen>(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag("base-url-screen") },
) {
val urlInput = KTextInputLayout { withId(R.id.url_input_layout) }
val urlInput = child<KNode> { hasTestTag("url-input-field") }
val proceedButton = KButton { withId(R.id.button) }
val urlInputLabel = unmergedChild<KNode, BaseUrlScreen> { hasTestTag("url-input-label") }
val proceedButton = child<KNode> { hasTestTag("proceed-button") }
val proceedButtonText =
unmergedChild<KNode, BaseUrlScreen> { hasTestTag("proceed-button-text") }
val progressBar = unmergedChild<KNode, BaseUrlScreen> { hasTestTag("progress-indicator") }
val progressBar = KProgressBar { withId(R.id.progress)}
}

View File

@@ -1,16 +1,19 @@
package gq.kirmanak.mealient.screen
import com.kaspersky.kaspresso.screens.KScreen
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.ui.disclaimer.DisclaimerFragment
import io.github.kakaocup.kakao.text.KButton
import io.github.kakaocup.kakao.text.KTextView
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.KNode
object DisclaimerScreen : KScreen<DisclaimerScreen>() {
override val layoutId = R.layout.fragment_disclaimer
override val viewClass = DisclaimerFragment::class.java
internal class DisclaimerScreen(
semanticsProvider: SemanticsNodeInteractionsProvider,
) : ComposeScreen<DisclaimerScreen>(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag("disclaimer-screen") },
) {
val okayButton = KButton { withId(R.id.okay) }
val okayButton = child<KNode> { hasTestTag("okay-button") }
val disclaimerText = KTextView { withId(R.id.main_text) }
val okayButtonText = unmergedChild<KNode, DisclaimerScreen> { hasTestTag("okay-button-text") }
val disclaimerText = child<KNode> { hasTestTag("disclaimer-text") }
}

View File

@@ -0,0 +1,12 @@
package gq.kirmanak.mealient.screen
import io.github.kakaocup.compose.node.builder.ViewBuilder
import io.github.kakaocup.compose.node.core.BaseNode
inline fun <reified N, T : BaseNode<T>> BaseNode<T>.unmergedChild(
function: ViewBuilder.() -> Unit,
): N = child<N> {
useUnmergedTree = true
function()
}

View File

@@ -1,20 +1,18 @@
package gq.kirmanak.mealient.screen
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.printToLog
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import org.junit.rules.TestRule
class RecipesListScreen<R : TestRule, A : ComponentActivity>(
semanticsProvider: AndroidComposeTestRule<R, A>,
) : ComposeScreen<RecipesListScreen<R, A>>(semanticsProvider) {
internal class RecipesListScreen(
semanticsProvider: SemanticsNodeInteractionsProvider,
) : ComposeScreen<RecipesListScreen>(semanticsProvider) {
init {
semanticsProvider.onRoot(useUnmergedTree = true).printToLog("RecipesListScreen")
val openDrawerButton = child<KNode> { hasTestTag("open-drawer-button") }
val searchRecipesField = child<KNode> { hasTestTag("search-recipes-field") }
val emptyListErrorText = unmergedChild<KNode, RecipesListScreen> {
hasTestTag("empty-list-error-text")
}
val errorText: KNode = child { hasTestTag("empty-list-error-text") }
}

View File

@@ -1,7 +1,11 @@
package gq.kirmanak.mealient.data.baseurl
import kotlinx.coroutines.flow.Flow
interface ServerInfoRepo {
val baseUrlFlow: Flow<String?>
suspend fun getUrl(): String?
suspend fun tryBaseURL(baseURL: String): Result<Unit>

View File

@@ -2,6 +2,7 @@ package gq.kirmanak.mealient.data.baseurl
import gq.kirmanak.mealient.datasource.ServerUrlProvider
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class ServerInfoRepoImpl @Inject constructor(
@@ -10,6 +11,9 @@ class ServerInfoRepoImpl @Inject constructor(
private val logger: Logger,
) : ServerInfoRepo, ServerUrlProvider {
override val baseUrlFlow: Flow<String?>
get() = serverInfoStorage.baseUrlFlow
override suspend fun getUrl(): String? {
val result = serverInfoStorage.getBaseURL()
logger.v { "getUrl() returned: $result" }

View File

@@ -1,7 +1,11 @@
package gq.kirmanak.mealient.data.baseurl
import kotlinx.coroutines.flow.Flow
interface ServerInfoStorage {
val baseUrlFlow: Flow<String?>
suspend fun getBaseURL(): String?
suspend fun storeBaseURL(baseURL: String?)

View File

@@ -29,9 +29,10 @@ class BaseUrlLogRedactor @Inject constructor(
private fun setInitialBaseUrl() {
val scope = CoroutineScope(dispatchers.default + SupervisorJob())
scope.launch {
val baseUrl = preferencesStorage.getValue(preferencesStorage.baseUrlKey)
hostState.compareAndSet(
expect = null,
update = preferencesStorage.getValue(preferencesStorage.baseUrlKey)
update = baseUrl?.extractHost()
)
}
}
@@ -40,7 +41,6 @@ class BaseUrlLogRedactor @Inject constructor(
hostState.value = baseUrl.extractHost()
}
override fun redact(message: String): String {
val host = hostState.value
return when {

View File

@@ -3,6 +3,7 @@ package gq.kirmanak.mealient.data.baseurl.impl
import androidx.datastore.preferences.core.Preferences
import gq.kirmanak.mealient.data.baseurl.ServerInfoStorage
import gq.kirmanak.mealient.data.storage.PreferencesStorage
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class ServerInfoStorageImpl @Inject constructor(
@@ -12,6 +13,9 @@ class ServerInfoStorageImpl @Inject constructor(
private val baseUrlKey: Preferences.Key<String>
get() = preferencesStorage.baseUrlKey
override val baseUrlFlow: Flow<String?>
get() = preferencesStorage.valueUpdates(baseUrlKey)
override suspend fun getBaseURL(): String? = getValue(baseUrlKey)
override suspend fun storeBaseURL(baseURL: String?) {

View File

@@ -1,15 +0,0 @@
package gq.kirmanak.mealient.extensions
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
fun <T> Fragment.collectWhenViewResumed(flow: Flow<T>, collector: FlowCollector<T>) {
viewLifecycleOwner.collectWhenResumed(flow, collector)
}
fun Fragment.showLongToast(@StringRes text: Int) = context?.showLongToast(text) != null
fun Fragment.showLongToast(text: String) = context?.showLongToast(text) != null

View File

@@ -4,59 +4,16 @@ import android.content.Context
import android.content.SharedPreferences
import android.content.res.Configuration.UI_MODE_NIGHT_MASK
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import android.content.res.Resources
import android.os.Build
import android.view.View
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.core.content.getSystemService
import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.asLiveData
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import androidx.viewbinding.ViewBinding
import com.google.android.material.textfield.TextInputLayout
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.channels.ChannelResult
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.onClosed
import kotlinx.coroutines.channels.onFailure
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.launch
fun SwipeRefreshLayout.refreshRequestFlow(logger: Logger): Flow<Unit> = callbackFlow {
logger.v { "refreshRequestFlow() called" }
val listener = SwipeRefreshLayout.OnRefreshListener {
logger.v { "refreshRequestFlow: listener called" }
trySend(Unit).logErrors("refreshesFlow", logger)
}
setOnRefreshListener(listener)
awaitClose {
logger.v { "Removing refresh request listener" }
setOnRefreshListener(null)
}
}
fun TextView.textChangesLiveData(logger: Logger): LiveData<CharSequence?> = callbackFlow {
logger.v { "textChangesLiveData() called" }
val textWatcher = doAfterTextChanged {
trySend(it).logErrors("textChangesFlow", logger)
}
awaitClose {
logger.d { "textChangesLiveData: flow is closing" }
removeTextChangedListener(textWatcher)
}
}.asLiveData() // Use asLiveData() to make sure close() is called with a delay to avoid IndexOutOfBoundsException
fun <T> ChannelResult<T>.logErrors(methodName: String, logger: Logger): ChannelResult<T> {
onFailure { logger.e(it) { "$methodName: can't send event" } }
@@ -64,30 +21,6 @@ fun <T> ChannelResult<T>.logErrors(methodName: String, logger: Logger): ChannelR
return this
}
fun EditText.checkIfInputIsEmpty(
inputLayout: TextInputLayout,
lifecycleOwner: LifecycleOwner,
@StringRes stringId: Int,
trim: Boolean = true,
logger: Logger,
): String? {
val input = if (trim) text?.trim() else text
val text = input?.toString().orEmpty()
return text.ifEmpty {
inputLayout.error = resources.getString(stringId)
val textChangesLiveData = textChangesLiveData(logger)
textChangesLiveData.observe(lifecycleOwner, object : Observer<CharSequence?> {
override fun onChanged(value: CharSequence?) {
if (value.isNullOrBlank().not()) {
inputLayout.error = null
textChangesLiveData.removeObserver(this)
}
}
})
null
}
}
fun <T> SharedPreferences.prefsChangeFlow(
logger: Logger,
valueReader: SharedPreferences.() -> T,
@@ -99,16 +32,6 @@ fun <T> SharedPreferences.prefsChangeFlow(
awaitClose { unregisterOnSharedPreferenceChangeListener(listener) }
}
fun <T> LiveData<T>.observeOnce(lifecycleOwner: LifecycleOwner, observer: Observer<T>) {
observe(lifecycleOwner, object : Observer<T> {
override fun onChanged(value: T) {
removeObserver(this)
observer.onChanged(value)
}
})
}
fun Context.showLongToast(text: String) = showToast(text, Toast.LENGTH_LONG)
fun Context.showLongToast(@StringRes text: Int) = showLongToast(getString(text))
@@ -117,23 +40,8 @@ private fun Context.showToast(text: String, length: Int) {
Toast.makeText(this, text, length).show()
}
fun View.hideKeyboard() {
val imm = context.getSystemService<InputMethodManager>()
imm?.hideSoftInputFromWindow(windowToken, 0)
}
fun Context.isDarkThemeOn(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) resources.configuration.isNightModeActive
else resources.configuration.uiMode and UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES
}
fun <T> LifecycleOwner.collectWhenResumed(flow: Flow<T>, collector: FlowCollector<T>) {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
flow.collect(collector)
}
}
}
val <T : ViewBinding> T.resources: Resources
get() = root.resources

View File

@@ -0,0 +1,61 @@
package gq.kirmanak.mealient.ui
import com.ramcosta.composedestinations.spec.DestinationSpec
import com.ramcosta.composedestinations.spec.NavGraphSpec
import com.ramcosta.composedestinations.spec.Route
import gq.kirmanak.mealient.shopping_lists.ui.destinations.ShoppingListScreenDestination
import gq.kirmanak.mealient.shopping_lists.ui.destinations.ShoppingListsScreenDestination
import gq.kirmanak.mealient.ui.destinations.AddRecipeScreenDestination
import gq.kirmanak.mealient.ui.destinations.AuthenticationScreenDestination
import gq.kirmanak.mealient.ui.destinations.BaseURLScreenDestination
import gq.kirmanak.mealient.ui.destinations.DisclaimerScreenDestination
import gq.kirmanak.mealient.ui.destinations.RecipeScreenDestination
import gq.kirmanak.mealient.ui.destinations.RecipesListDestination
internal object NavGraphs {
val recipes: NavGraphSpec = NavGraphImpl(
route = "recipes",
startRoute = RecipesListDestination,
destinations = listOf(
RecipesListDestination,
RecipeScreenDestination,
),
)
val shoppingLists: NavGraphSpec = NavGraphImpl(
route = "shopping_lists",
startRoute = ShoppingListsScreenDestination,
destinations = listOf(
ShoppingListsScreenDestination,
ShoppingListScreenDestination,
),
)
val root: NavGraphSpec = NavGraphImpl(
route = "root",
startRoute = recipes,
destinations = listOf(
AddRecipeScreenDestination,
DisclaimerScreenDestination,
BaseURLScreenDestination,
AuthenticationScreenDestination,
),
nestedNavGraphs = listOf(
recipes,
shoppingLists,
),
)
}
private data class NavGraphImpl(
override val route: String,
override val startRoute: Route,
val destinations: List<DestinationSpec<*>>,
override val nestedNavGraphs: List<NavGraphSpec> = emptyList()
) : NavGraphSpec {
override val destinationsByRoute: Map<String, DestinationSpec<*>> =
destinations.associateBy { it.route }
}

View File

@@ -0,0 +1,134 @@
package gq.kirmanak.mealient.ui.activity
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.filled.ShoppingCart
import androidx.compose.material.icons.filled.SyncAlt
import androidx.compose.material3.DrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavController
import com.ramcosta.composedestinations.navigation.navigate
import com.ramcosta.composedestinations.navigation.popUpTo
import com.ramcosta.composedestinations.spec.DestinationSpec
import com.ramcosta.composedestinations.spec.Direction
import com.ramcosta.composedestinations.spec.NavGraphSpec
import com.ramcosta.composedestinations.utils.contains
import com.ramcosta.composedestinations.utils.currentDestinationAsState
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.ui.NavGraphs
import gq.kirmanak.mealient.ui.components.DrawerItem
import gq.kirmanak.mealient.ui.destinations.AddRecipeScreenDestination
import gq.kirmanak.mealient.ui.destinations.BaseURLScreenDestination
import gq.kirmanak.mealient.ui.destinations.RecipesListDestination
import kotlinx.coroutines.launch
@Composable
internal fun createDrawerItems(
navController: NavController,
onEvent: (AppEvent) -> Unit
): List<DrawerItem> {
val coroutineScope = rememberCoroutineScope()
fun createNavigationItem(
@StringRes nameRes: Int,
icon: ImageVector,
direction: Direction,
): DrawerItem = DrawerItemImpl(
nameRes = nameRes,
icon = icon,
isSelected = {
val currentDestination by navController.currentDestinationAsState()
isDestinationSelected(currentDestination, direction)
},
onClick = { drawerState ->
coroutineScope.launch {
drawerState.close()
navController.navigate(direction) {
launchSingleTop = true
popUpTo(NavGraphs.root)
}
}
},
)
fun createActionItem(
@StringRes nameRes: Int,
icon: ImageVector,
appEvent: AppEvent,
): DrawerItem = DrawerItemImpl(
nameRes = nameRes,
icon = icon,
isSelected = { false },
onClick = { drawerState ->
coroutineScope.launch {
drawerState.close()
onEvent(appEvent)
}
},
)
return listOf(
createNavigationItem(
nameRes = R.string.menu_navigation_drawer_recipes_list,
icon = Icons.Default.List,
direction = RecipesListDestination,
),
createNavigationItem(
nameRes = R.string.menu_navigation_drawer_add_recipe,
icon = Icons.Default.Add,
direction = AddRecipeScreenDestination,
),
createNavigationItem(
nameRes = R.string.menu_navigation_drawer_shopping_lists,
icon = Icons.Default.ShoppingCart,
direction = NavGraphs.shoppingLists,
),
createNavigationItem(
nameRes = R.string.menu_navigation_drawer_change_url,
icon = Icons.Default.SyncAlt,
direction = BaseURLScreenDestination,
),
createActionItem(
nameRes = R.string.menu_navigation_drawer_logout,
icon = Icons.Default.Logout,
appEvent = AppEvent.Logout,
),
createActionItem(
nameRes = R.string.menu_navigation_drawer_email_logs,
icon = Icons.Default.Email,
appEvent = AppEvent.EmailLogs,
)
)
}
internal class DrawerItemImpl(
@StringRes private val nameRes: Int,
private val isSelected: @Composable () -> Boolean,
override val icon: ImageVector,
override val onClick: (DrawerState) -> Unit,
) : DrawerItem {
@Composable
override fun getName(): String = stringResource(id = nameRes)
@Composable
override fun isSelected(): Boolean = isSelected.invoke()
}
private fun isDestinationSelected(
currentDestination: DestinationSpec<*>?,
direction: Direction,
): Boolean = when {
currentDestination == null -> false
currentDestination == direction -> true
direction is NavGraphSpec && direction.contains(currentDestination) -> true
else -> false
}

View File

@@ -1,185 +1,42 @@
package gq.kirmanak.mealient.ui.activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.MenuItem
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.core.content.FileProvider
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.isVisible
import androidx.core.view.iterator
import androidx.drawerlayout.widget.DrawerLayout
import androidx.navigation.NavController
import androidx.navigation.NavDirections
import androidx.navigation.fragment.NavHostFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import androidx.core.view.WindowInsetsControllerCompat
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.NavGraphDirections.Companion.actionGlobalAddRecipeFragment
import gq.kirmanak.mealient.NavGraphDirections.Companion.actionGlobalAuthenticationFragment
import gq.kirmanak.mealient.NavGraphDirections.Companion.actionGlobalBaseURLFragment
import gq.kirmanak.mealient.NavGraphDirections.Companion.actionGlobalRecipesListFragment
import gq.kirmanak.mealient.NavGraphDirections.Companion.actionGlobalShoppingListsFragment
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.databinding.MainActivityBinding
import gq.kirmanak.mealient.extensions.collectWhenResumed
import gq.kirmanak.mealient.extensions.observeOnce
import gq.kirmanak.mealient.logging.getLogFile
import gq.kirmanak.mealient.ui.ActivityUiState
import gq.kirmanak.mealient.ui.BaseActivity
import gq.kirmanak.mealient.ui.CheckableMenuItem
private const val EMAIL_FOR_LOGS = "mealient@gmail.com"
import gq.kirmanak.mealient.extensions.isDarkThemeOn
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.AppTheme
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : BaseActivity<MainActivityBinding>(
binder = MainActivityBinding::bind,
containerId = R.id.drawer,
layoutRes = R.layout.main_activity,
) {
class MainActivity : ComponentActivity() {
@Inject
lateinit var logger: Logger
private val viewModel by viewModels<MainActivityViewModel>()
private val navController: NavController
get() = binding.navHost.getFragment<NavHostFragment>().navController
override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState)
splashScreen.setKeepOnScreenCondition { viewModel.startDestination.value == null }
setupUi()
configureNavGraph()
}
private fun configureNavGraph() {
logger.v { "configureNavGraph() called" }
viewModel.startDestination.observeOnce(this) {
logger.d { "configureNavGraph: received destination" }
val controller = navController
val graph = controller.navInflater.inflate(R.navigation.nav_graph)
graph.setStartDestination(it.startDestinationId)
controller.setGraph(graph, it.startDestinationArgs)
logger.v { "onCreate() called with: savedInstanceState = $savedInstanceState" }
with(WindowInsetsControllerCompat(window, window.decorView)) {
val isAppearanceLightBars = !isDarkThemeOn()
isAppearanceLightNavigationBars = isAppearanceLightBars
isAppearanceLightStatusBars = isAppearanceLightBars
}
}
private fun setupUi() {
binding.toolbar.setNavigationOnClickListener {
binding.toolbar.clearSearchFocus()
binding.drawer.open()
splashScreen.setKeepOnScreenCondition {
viewModel.appState.value.forcedRoute == ForcedDestination.Undefined
}
binding.toolbar.onSearchQueryChanged { query ->
viewModel.onSearchQuery(query.trim().takeUnless { it.isEmpty() })
}
binding.navigationView.setNavigationItemSelectedListener(::onNavigationItemSelected)
collectWhenResumed(viewModel.uiState, ::onUiStateChange)
collectWhenResumed(viewModel.clearSearchViewFocus) {
logger.d { "clearSearchViewFocus(): received event" }
binding.toolbar.clearSearchFocus()
}
}
private fun onNavigationItemSelected(menuItem: MenuItem): Boolean {
logger.v { "onNavigationItemSelected() called with: menuItem = $menuItem" }
if (menuItem.isChecked) {
logger.d { "Not navigating because it is the current destination" }
binding.drawer.close()
return true
}
val directions = when (menuItem.itemId) {
R.id.add_recipe -> actionGlobalAddRecipeFragment()
R.id.recipes_list -> actionGlobalRecipesListFragment()
R.id.shopping_lists -> actionGlobalShoppingListsFragment()
R.id.change_url -> actionGlobalBaseURLFragment(false)
R.id.login -> actionGlobalAuthenticationFragment()
R.id.logout -> {
viewModel.logout()
return true
setContent {
AppTheme {
MealientApp(viewModel)
}
R.id.email_logs -> {
emailLogs()
return true
}
else -> throw IllegalArgumentException("Unknown menu item id: ${menuItem.itemId}")
}
menuItem.isChecked = true
navigateTo(directions)
return true
}
private fun emailLogs() {
MaterialAlertDialogBuilder(this)
.setMessage(R.string.activity_main_email_logs_confirmation_message)
.setTitle(R.string.activity_main_email_logs_confirmation_title)
.setPositiveButton(R.string.activity_main_email_logs_confirmation_positive) { _, _ -> doEmailLogs() }
.setNegativeButton(R.string.activity_main_email_logs_confirmation_negative, null)
.show()
}
private fun doEmailLogs() {
val logFileUri = try {
FileProvider.getUriForFile(this, "$packageName.provider", getLogFile())
} catch (e: Exception) {
return
}
val emailIntent = buildIntent(logFileUri)
val chooserIntent = Intent.createChooser(emailIntent, null)
startActivity(chooserIntent)
}
private fun buildIntent(logFileUri: Uri?): Intent {
val emailIntent = Intent(Intent.ACTION_SEND)
val to = arrayOf(EMAIL_FOR_LOGS)
emailIntent.setType("text/plain")
emailIntent.putExtra(Intent.EXTRA_EMAIL, to)
emailIntent.putExtra(Intent.EXTRA_STREAM, logFileUri)
emailIntent.putExtra(
Intent.EXTRA_SUBJECT,
getString(R.string.activity_main_email_logs_subject)
)
return emailIntent
}
private fun onUiStateChange(uiState: ActivityUiState) {
logger.v { "onUiStateChange() called with: uiState = $uiState" }
val checkedMenuItem = when (uiState.checkedMenuItem) {
CheckableMenuItem.ShoppingLists -> R.id.shopping_lists
CheckableMenuItem.RecipesList -> R.id.recipes_list
CheckableMenuItem.AddRecipe -> R.id.add_recipe
CheckableMenuItem.ChangeUrl -> R.id.change_url
CheckableMenuItem.Login -> R.id.login
null -> null
}
for (menuItem in binding.navigationView.menu.iterator()) {
val itemId = menuItem.itemId
when (itemId) {
R.id.logout -> menuItem.isVisible = uiState.canShowLogout
R.id.login -> menuItem.isVisible = uiState.canShowLogin
}
menuItem.isChecked = itemId == checkedMenuItem
}
binding.toolbar.isVisible = uiState.navigationVisible
binding.root.setDrawerLockMode(
if (uiState.navigationVisible) {
DrawerLayout.LOCK_MODE_UNLOCKED
} else {
DrawerLayout.LOCK_MODE_LOCKED_CLOSED
}
)
binding.toolbar.isSearchVisible = uiState.searchVisible
if (uiState.searchVisible) {
binding.toolbar.setBackgroundResource(R.drawable.bg_toolbar)
} else {
binding.toolbar.background = null
}
}
private fun navigateTo(directions: NavDirections) {
logger.v { "navigateTo() called with: directions = $directions" }
binding.drawer.close()
navController.navigate(directions)
}
}

View File

@@ -1,7 +1,9 @@
package gq.kirmanak.mealient.ui.activity
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import android.app.Application
import android.content.Intent
import androidx.annotation.StringRes
import androidx.core.content.FileProvider
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -9,74 +11,202 @@ import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage
import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.ActivityUiState
import gq.kirmanak.mealient.ui.ActivityUiStateController
import gq.kirmanak.mealient.ui.baseurl.BaseURLFragmentArgs
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import gq.kirmanak.mealient.logging.getLogFile
import gq.kirmanak.mealient.ui.destinations.AuthenticationScreenDestination
import gq.kirmanak.mealient.ui.destinations.BaseURLScreenDestination
import gq.kirmanak.mealient.ui.destinations.DirectionDestination
import gq.kirmanak.mealient.ui.destinations.DisclaimerScreenDestination
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class MainActivityViewModel @Inject constructor(
internal class MainActivityViewModel @Inject constructor(
private val application: Application,
private val authRepo: AuthRepo,
private val logger: Logger,
private val disclaimerStorage: DisclaimerStorage,
private val serverInfoRepo: ServerInfoRepo,
private val recipeRepo: RecipeRepo,
private val activityUiStateController: ActivityUiStateController,
) : ViewModel() {
val uiState: StateFlow<ActivityUiState> = activityUiStateController.getUiStateFlow()
private val _startDestination = MutableLiveData<StartDestinationInfo>()
val startDestination: LiveData<StartDestinationInfo> = _startDestination
private val _clearSearchViewFocusChannel = Channel<Unit>()
val clearSearchViewFocus: Flow<Unit> = _clearSearchViewFocusChannel.receiveAsFlow()
private val _appState = MutableStateFlow(MealientAppState())
val appState: StateFlow<MealientAppState> get() = _appState.asStateFlow()
init {
authRepo.isAuthorizedFlow
.onEach { isAuthorized -> updateUiState { it.copy(isAuthorized = isAuthorized) } }
.launchIn(viewModelScope)
checkForcedDestination()
}
private fun checkForcedDestination() {
logger.v { "checkForcedDestination() called" }
val baseUrlSetState = serverInfoRepo.baseUrlFlow.map { it != null }
val tokenExistsState = authRepo.isAuthorizedFlow
val disclaimerAcceptedState = disclaimerStorage.isDisclaimerAcceptedFlow
viewModelScope.launch {
_startDestination.value = when {
!disclaimerStorage.isDisclaimerAccepted() -> {
StartDestinationInfo(R.id.disclaimerFragment)
}
serverInfoRepo.getUrl() == null -> {
StartDestinationInfo(R.id.baseURLFragment, BaseURLFragmentArgs(true).toBundle())
}
else -> {
StartDestinationInfo(R.id.recipesListFragment)
combine(
baseUrlSetState,
tokenExistsState,
disclaimerAcceptedState,
) { baseUrlSet, tokenExists, disclaimerAccepted ->
logger.d { "baseUrlSet = $baseUrlSet, tokenExists = $tokenExists, disclaimerAccepted = $disclaimerAccepted" }
when {
!disclaimerAccepted -> ForcedDestination.Destination(DisclaimerScreenDestination)
!baseUrlSet -> ForcedDestination.Destination(BaseURLScreenDestination)
!tokenExists -> ForcedDestination.Destination(AuthenticationScreenDestination)
else -> ForcedDestination.None
}
}.collect { destination ->
logger.v { "destination = $destination" }
_appState.update { it.copy(forcedRoute = destination) }
}
}
}
fun updateUiState(updater: (ActivityUiState) -> ActivityUiState) {
activityUiStateController.updateUiState(updater)
fun onEvent(event: AppEvent) {
logger.v { "onEvent() called with: event = $event" }
when (event) {
is AppEvent.Logout -> {
_appState.update {
it.copy(dialogState = logoutConfirmationDialog())
}
}
is AppEvent.LogoutConfirm -> {
_appState.update { it.copy(dialogState = null) }
viewModelScope.launch { authRepo.logout() }
}
is AppEvent.EmailLogs -> {
_appState.update {
it.copy(dialogState = emailConfirmationDialog())
}
}
is AppEvent.EmailLogsConfirm -> {
_appState.update {
it.copy(
dialogState = null,
intentToLaunch = logEmailIntent(),
)
}
}
is AppEvent.DismissDialog -> {
_appState.update { it.copy(dialogState = null) }
}
is AppEvent.LaunchedIntent -> {
_appState.update { it.copy(intentToLaunch = null) }
}
}
}
fun logout() {
logger.v { "logout() called" }
viewModelScope.launch { authRepo.logout() }
private fun logEmailIntent(): Intent? {
val logFileUri = try {
FileProvider.getUriForFile(
/* context = */ application,
/* authority = */ "${application.packageName}.provider",
/* file = */ application.getLogFile(),
)
} catch (e: IllegalArgumentException) {
logger.e(e) { "Failed to get log file URI" }
return null
}
if (logFileUri == null) {
logger.e { "logFileUri is null" }
return null
}
logger.v { "logFileUri = $logFileUri" }
val emailIntent = Intent(Intent.ACTION_SEND).apply {
val subject = application.getString(R.string.activity_main_email_logs_subject)
val to = arrayOf(EMAIL_FOR_LOGS)
type = "text/plain"
putExtra(Intent.EXTRA_EMAIL, to)
putExtra(Intent.EXTRA_STREAM, logFileUri)
putExtra(Intent.EXTRA_SUBJECT, subject)
}
return Intent.createChooser(emailIntent, null)
}
fun onSearchQuery(query: String?) {
logger.v { "onSearchQuery() called with: query = $query" }
recipeRepo.updateNameQuery(query)
}
private fun logoutConfirmationDialog() = DialogState(
title = R.string.activity_main_logout_confirmation_title,
message = R.string.activity_main_logout_confirmation_message,
positiveButton = R.string.activity_main_logout_confirmation_positive,
negativeButton = R.string.activity_main_logout_confirmation_negative,
onPositiveClick = AppEvent.LogoutConfirm,
)
fun clearSearchViewFocus() {
logger.v { "clearSearchViewFocus() called" }
_clearSearchViewFocusChannel.trySend(Unit)
private fun emailConfirmationDialog() = DialogState(
title = R.string.activity_main_email_logs_confirmation_title,
message = R.string.activity_main_email_logs_confirmation_message,
positiveButton = R.string.activity_main_email_logs_confirmation_positive,
negativeButton = R.string.activity_main_email_logs_confirmation_negative,
onPositiveClick = AppEvent.EmailLogsConfirm,
)
companion object {
private const val EMAIL_FOR_LOGS = "mealient@gmail.com"
}
}
internal data class MealientAppState(
val forcedRoute: ForcedDestination = ForcedDestination.Undefined,
val searchQuery: String = "",
val dialogState: DialogState? = null,
val intentToLaunch: Intent? = null,
)
internal data class DialogState(
@StringRes val title: Int,
@StringRes val message: Int,
@StringRes val positiveButton: Int,
@StringRes val negativeButton: Int,
val onPositiveClick: AppEvent,
val onDismiss: AppEvent = AppEvent.DismissDialog,
val onNegativeClick: AppEvent = onDismiss,
)
internal sealed interface ForcedDestination {
/**
* Force navigation is required
*/
data class Destination(
val route: DirectionDestination,
) : ForcedDestination
/**
* The conditions were checked, no force navigation required
*/
data object None : ForcedDestination
/**
* The conditions were not checked yet
*/
data object Undefined : ForcedDestination
}
internal sealed interface AppEvent {
data object Logout : AppEvent
data object EmailLogs : AppEvent
data object DismissDialog : AppEvent
data object LogoutConfirm : AppEvent
data object EmailLogsConfirm : AppEvent
data object LaunchedIntent : AppEvent
}

View File

@@ -0,0 +1,178 @@
package gq.kirmanak.mealient.ui.activity
import android.content.Intent
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.res.stringResource
import androidx.navigation.NavHostController
import com.ramcosta.composedestinations.DestinationsNavHost
import com.ramcosta.composedestinations.animations.defaults.RootNavGraphDefaultAnimations
import com.ramcosta.composedestinations.navigation.dependency
import com.ramcosta.composedestinations.navigation.navigate
import com.ramcosta.composedestinations.navigation.popUpTo
import com.ramcosta.composedestinations.rememberNavHostEngine
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 gq.kirmanak.mealient.ui.NavGraphs
import gq.kirmanak.mealient.ui.components.rememberBaseScreenState
@Composable
internal fun MealientApp(
viewModel: MainActivityViewModel,
) {
val state by viewModel.appState.collectAsState()
MealientApp(
state = state,
onEvent = viewModel::onEvent,
)
}
@Composable
private fun MealientApp(
state: MealientAppState,
onEvent: (AppEvent) -> Unit,
) {
val engine = rememberNavHostEngine(
rootDefaultAnimations = RootNavGraphDefaultAnimations.ACCOMPANIST_FADING,
)
val controller = engine.rememberNavController()
val currentDestinationState = controller.currentDestinationAsState()
val currentDestination = currentDestinationState.value
ForceNavigationEffect(
currentDestination = currentDestination,
controller = controller,
forcedDestination = state.forcedRoute
)
IntentLaunchEffect(
intent = state.intentToLaunch,
onEvent = onEvent,
)
if (state.dialogState != null) {
MealientDialog(
dialogState = state.dialogState,
onEvent = onEvent,
)
}
AppContent(
engine = engine,
controller = controller,
startRoute = (state.forcedRoute as? ForcedDestination.Destination)?.route,
onEvent = onEvent,
)
}
@Composable
private fun IntentLaunchEffect(
intent: Intent?,
onEvent: (AppEvent) -> Unit,
) {
val context = LocalContext.current
LaunchedEffect(intent) {
if (intent != null) {
context.startActivity(intent)
onEvent(AppEvent.LaunchedIntent)
}
}
}
@Composable
private fun MealientDialog(
dialogState: DialogState,
onEvent: (AppEvent) -> Unit,
) {
AlertDialog(
onDismissRequest = {
onEvent(dialogState.onDismiss)
},
confirmButton = {
TextButton(
onClick = { onEvent(dialogState.onPositiveClick) },
) {
Text(
text = stringResource(id = dialogState.positiveButton),
)
}
},
dismissButton = {
TextButton(
onClick = { onEvent(dialogState.onNegativeClick) },
) {
Text(
text = stringResource(id = dialogState.negativeButton),
)
}
},
title = {
Text(
text = stringResource(id = dialogState.title),
)
},
text = {
Text(
text = stringResource(id = dialogState.message),
)
},
)
}
@Composable
private fun ForceNavigationEffect(
currentDestination: DestinationSpec<*>?,
controller: NavHostController,
forcedDestination: ForcedDestination,
) {
LaunchedEffect(currentDestination, forcedDestination) {
if (
forcedDestination is ForcedDestination.Destination &&
currentDestination != null &&
currentDestination != forcedDestination.route
) {
controller.navigate(forcedDestination.route) {
popUpTo(currentDestination) {
inclusive = true
}
}
}
}
}
@Composable
private fun AppContent(
engine: NavHostEngine,
controller: NavHostController,
startRoute: Route?,
onEvent: (AppEvent) -> Unit,
) {
val drawerItems = createDrawerItems(
navController = controller,
onEvent = onEvent,
)
val baseScreenState = rememberBaseScreenState(
drawerItems = drawerItems,
)
DestinationsNavHost(
navGraph = NavGraphs.root,
engine = engine,
navController = controller,
startRoute = startRoute ?: NavGraphs.root.startRoute,
dependenciesContainerBuilder = {
dependency(baseScreenState)
}
)
}

View File

@@ -1,9 +0,0 @@
package gq.kirmanak.mealient.ui.activity
import android.os.Bundle
import androidx.annotation.IdRes
data class StartDestinationInfo(
@IdRes val startDestinationId: Int,
val startDestinationArgs: Bundle? = null,
)

View File

@@ -1,47 +0,0 @@
package gq.kirmanak.mealient.ui.activity
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import androidx.annotation.AttrRes
import androidx.annotation.StyleRes
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged
import gq.kirmanak.mealient.databinding.ViewToolbarBinding
import gq.kirmanak.mealient.extensions.hideKeyboard
class ToolbarView @JvmOverloads constructor(
context: Context,
attributeSet: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0,
@StyleRes defStyleRes: Int = 0,
) : ConstraintLayout(context, attributeSet, defStyleAttr, defStyleRes) {
private lateinit var binding: ViewToolbarBinding
var isSearchVisible: Boolean
get() = binding.searchEdit.isVisible
set(value) {
binding.searchEdit.isVisible = value
}
override fun onFinishInflate() {
super.onFinishInflate()
val inflater = LayoutInflater.from(context)
binding = ViewToolbarBinding.inflate(inflater, this)
}
fun setNavigationOnClickListener(listener: OnClickListener?) {
binding.navigationIcon.setOnClickListener(listener)
}
fun onSearchQueryChanged(block: (String) -> Unit) {
binding.searchEdit.doAfterTextChanged { block(it.toString()) }
}
fun clearSearchFocus() {
binding.searchEdit.clearFocus()
hideKeyboard()
}
}

View File

@@ -1,186 +0,0 @@
package gq.kirmanak.mealient.ui.add
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.constraintlayout.helper.widget.Flow
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import by.kirich1409.viewbindingdelegate.viewBinding
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.databinding.FragmentAddRecipeBinding
import gq.kirmanak.mealient.databinding.ViewSingleInputBinding
import gq.kirmanak.mealient.datasource.models.AddRecipeInfo
import gq.kirmanak.mealient.datasource.models.AddRecipeIngredientInfo
import gq.kirmanak.mealient.datasource.models.AddRecipeInstructionInfo
import gq.kirmanak.mealient.datasource.models.AddRecipeSettingsInfo
import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
import gq.kirmanak.mealient.extensions.collectWhenViewResumed
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.CheckableMenuItem
import gq.kirmanak.mealient.ui.activity.MainActivityViewModel
import javax.inject.Inject
@AndroidEntryPoint
class AddRecipeFragment : Fragment(R.layout.fragment_add_recipe) {
private val binding by viewBinding(FragmentAddRecipeBinding::bind)
private val viewModel by viewModels<AddRecipeViewModel>()
private val activityViewModel by activityViewModels<MainActivityViewModel>()
@Inject
lateinit var logger: Logger
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
logger.v { "onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState" }
activityViewModel.updateUiState {
it.copy(
navigationVisible = true,
searchVisible = false,
checkedMenuItem = CheckableMenuItem.AddRecipe,
)
}
viewModel.loadPreservedRequest()
setupViews()
observeAddRecipeResult()
}
private fun observeAddRecipeResult() {
logger.v { "observeAddRecipeResult() called" }
collectWhenViewResumed(viewModel.addRecipeResult, ::onRecipeSaveResult)
}
private fun onRecipeSaveResult(isSuccessful: Boolean) = with(binding) {
logger.v { "onRecipeSaveResult() called with: isSuccessful = $isSuccessful" }
listOf(clearButton, saveRecipeButton).forEach { it.isEnabled = true }
val toastText = if (isSuccessful) {
R.string.fragment_add_recipe_save_success
} else {
R.string.fragment_add_recipe_save_error
}
Toast.makeText(requireContext(), getString(toastText), Toast.LENGTH_SHORT).show()
}
private fun setupViews() = with(binding) {
logger.v { "setupViews() called" }
saveRecipeButton.setOnClickListener {
recipeNameInput.checkIfInputIsEmpty(
inputLayout = recipeNameInputLayout,
lifecycleOwner = viewLifecycleOwner,
stringId = R.string.fragment_add_recipe_name_error,
logger = logger,
) ?: return@setOnClickListener
listOf(saveRecipeButton, clearButton).forEach { it.isEnabled = false }
viewModel.saveRecipe()
}
clearButton.setOnClickListener { viewModel.clear() }
newIngredientButton.setOnClickListener {
inflateInputRow(ingredientsFlow, R.string.fragment_add_recipe_ingredient_hint)
}
newInstructionButton.setOnClickListener {
inflateInputRow(instructionsFlow, R.string.fragment_add_recipe_instruction_hint)
}
listOf(
recipeNameInput,
recipeDescriptionInput,
recipeYieldInput
).forEach { it.doAfterTextChanged { saveValues() } }
listOf(
publicRecipe,
disableComments
).forEach { it.setOnCheckedChangeListener { _, _ -> saveValues() } }
collectWhenViewResumed(viewModel.preservedAddRecipeRequest, ::onSavedInputLoaded)
}
private fun inflateInputRow(flow: Flow, @StringRes hintId: Int, text: String? = null) {
logger.v { "inflateInputRow() called with: flow = $flow, hintId = $hintId, text = $text" }
val fragmentRoot = binding.holder
val inputBinding = ViewSingleInputBinding.inflate(layoutInflater, fragmentRoot, false)
val root = inputBinding.root
root.setHint(hintId)
val input = inputBinding.input
input.setText(text)
input.doAfterTextChanged { saveValues() }
root.id = View.generateViewId()
fragmentRoot.addView(root)
flow.addView(root)
root.setEndIconOnClickListener {
flow.removeView(root)
fragmentRoot.removeView(root)
}
}
private fun saveValues() = with(binding) {
logger.v { "saveValues() called" }
val instructions = parseInputRows(instructionsFlow).map { AddRecipeInstructionInfo(it) }
val ingredients = parseInputRows(ingredientsFlow).map { AddRecipeIngredientInfo(it) }
val settings = AddRecipeSettingsInfo(
public = publicRecipe.isChecked,
disableComments = disableComments.isChecked,
)
viewModel.preserve(
AddRecipeInfo(
name = recipeNameInput.text.toString(),
description = recipeDescriptionInput.text.toString(),
recipeYield = recipeYieldInput.text.toString(),
recipeIngredient = ingredients,
recipeInstructions = instructions,
settings = settings
)
)
}
private fun parseInputRows(flow: Flow): List<String> =
flow.referencedIds.asSequence()
.mapNotNull { binding.holder.findViewById(it) }
.map { ViewSingleInputBinding.bind(it) }
.map { it.input.text.toString() }
.filterNot { it.isBlank() }
.toList()
private fun onSavedInputLoaded(request: AddRecipeInfo) = with(binding) {
logger.v { "onSavedInputLoaded() called with: request = $request" }
request.recipeIngredient.map { it.note }
.showIn(ingredientsFlow, R.string.fragment_add_recipe_ingredient_hint)
request.recipeInstructions.map { it.text }
.showIn(instructionsFlow, R.string.fragment_add_recipe_instruction_hint)
recipeNameInput.setText(request.name)
recipeDescriptionInput.setText(request.description)
recipeYieldInput.setText(request.recipeYield)
publicRecipe.isChecked = request.settings.public
disableComments.isChecked = request.settings.disableComments
}
private fun Iterable<String>.showIn(flow: Flow, @StringRes hintId: Int) {
logger.v { "showIn() called with: flow = $flow, hintId = $hintId" }
flow.removeAllViews()
forEach { inflateInputRow(flow = flow, hintId = hintId, text = it) }
}
private fun Flow.removeAllViews() {
logger.v { "removeAllViews() called" }
for (id in referencedIds.iterator()) {
val view = binding.holder.findViewById<View>(id) ?: continue
removeView(view)
binding.holder.removeView(view)
}
}
}

View File

@@ -0,0 +1,312 @@
package gq.kirmanak.mealient.ui.add
import androidx.annotation.StringRes
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.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.hilt.navigation.compose.hiltViewModel
import com.ramcosta.composedestinations.annotation.Destination
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.Dimens
import gq.kirmanak.mealient.ui.components.BaseScreenState
import gq.kirmanak.mealient.ui.components.BaseScreenWithNavigation
import gq.kirmanak.mealient.ui.components.TopProgressIndicator
import gq.kirmanak.mealient.ui.components.previewBaseScreenState
import gq.kirmanak.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,
)
}
@Composable
internal fun AddRecipeScreen(
baseScreenState: BaseScreenState,
state: AddRecipeScreenState,
onEvent: (AddRecipeScreenEvent) -> Unit,
) {
val snackbarHostState = remember { SnackbarHostState() }
state.snackbarMessage?.let {
val message = when (it) {
is AddRecipeSnackbarMessage.Error -> stringResource(id = R.string.fragment_add_recipe_save_error)
is AddRecipeSnackbarMessage.Success -> stringResource(id = R.string.fragment_add_recipe_save_success)
}
LaunchedEffect(message) {
snackbarHostState.showSnackbar(message)
onEvent(AddRecipeScreenEvent.SnackbarShown)
}
} ?: run {
snackbarHostState.currentSnackbarData?.dismiss()
}
BaseScreenWithNavigation(
baseScreenState = baseScreenState,
snackbarHostState = snackbarHostState,
) { modifier ->
TopProgressIndicator(
modifier = modifier,
isLoading = state.isLoading,
) {
AddRecipeScreenContent(
state = state,
onEvent = onEvent,
)
}
}
}
@Composable
private fun AddRecipeScreenContent(
state: AddRecipeScreenState,
onEvent: (AddRecipeScreenEvent) -> Unit
) {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(Dimens.Medium),
verticalArrangement = Arrangement.spacedBy(Dimens.Medium),
horizontalAlignment = Alignment.End,
) {
item {
AddRecipeInputField(
input = state.recipeNameInput,
label = stringResource(id = R.string.fragment_add_recipe_recipe_name),
isLast = false,
onValueChange = { onEvent(AddRecipeScreenEvent.RecipeNameInput(it)) },
)
}
item {
AddRecipeInputField(
input = state.recipeDescriptionInput,
label = stringResource(id = R.string.fragment_add_recipe_recipe_description),
isLast = false,
onValueChange = { onEvent(AddRecipeScreenEvent.RecipeDescriptionInput(it)) },
)
}
item {
AddRecipeInputField(
input = state.recipeYieldInput,
label = stringResource(id = R.string.fragment_add_recipe_recipe_yield),
isLast = state.ingredients.isEmpty() && state.instructions.isEmpty(),
onValueChange = { onEvent(AddRecipeScreenEvent.RecipeYieldInput(it)) },
)
}
itemsIndexed(state.ingredients) { index, ingredient ->
AddRecipeInputField(
input = ingredient,
label = stringResource(id = R.string.fragment_add_recipe_ingredient_hint),
isLast = state.ingredients.lastIndex == index && state.instructions.isEmpty(),
onValueChange = {
onEvent(AddRecipeScreenEvent.IngredientInput(index, it))
},
onRemoveClick = {
onEvent(AddRecipeScreenEvent.RemoveIngredientClick(index))
},
)
}
item {
Button(
onClick = {
onEvent(AddRecipeScreenEvent.AddIngredientClick)
},
) {
Text(
text = stringResource(id = R.string.fragment_add_recipe_new_ingredient),
)
}
}
itemsIndexed(state.instructions) { index, instruction ->
AddRecipeInputField(
input = instruction,
label = stringResource(id = R.string.fragment_add_recipe_instruction_hint),
isLast = state.instructions.lastIndex == index,
onValueChange = {
onEvent(
AddRecipeScreenEvent.InstructionInput(index, it)
)
},
onRemoveClick = {
onEvent(AddRecipeScreenEvent.RemoveInstructionClick(index))
},
)
}
item {
Button(
onClick = {
onEvent(AddRecipeScreenEvent.AddInstructionClick)
},
) {
Text(
text = stringResource(id = R.string.fragment_add_recipe_new_instruction),
)
}
}
item {
AddRecipeSwitch(
name = R.string.fragment_add_recipe_public_recipe,
checked = state.isPublicRecipe,
onCheckedChange = { onEvent(AddRecipeScreenEvent.PublicRecipeToggle) },
)
}
item {
AddRecipeSwitch(
name = R.string.fragment_add_recipe_disable_comments,
checked = state.disableComments,
onCheckedChange = { onEvent(AddRecipeScreenEvent.DisableCommentsToggle) },
)
}
item {
AddRecipeActions(
state = state,
onEvent = onEvent,
)
}
}
}
@Composable
private fun AddRecipeActions(
state: AddRecipeScreenState,
onEvent: (AddRecipeScreenEvent) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Dimens.Large, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
) {
Button(
enabled = state.clearButtonEnabled,
onClick = {
onEvent(AddRecipeScreenEvent.ClearInputClick)
},
) {
Text(
text = stringResource(id = R.string.fragment_add_recipe_clear_button),
)
}
Button(
enabled = state.saveButtonEnabled,
onClick = {
onEvent(AddRecipeScreenEvent.SaveRecipeClick)
},
) {
Text(
text = stringResource(id = R.string.fragment_add_recipe_save_button),
)
}
}
}
@Composable
private fun AddRecipeSwitch(
@StringRes name: Int,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Dimens.Medium),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(id = name),
)
Spacer(modifier = Modifier.weight(1f))
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
)
}
}
@Composable
private fun AddRecipeInputField(
input: String,
label: String,
isLast: Boolean,
onValueChange: (String) -> Unit,
onRemoveClick: (() -> Unit)? = null,
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth(),
value = input,
onValueChange = onValueChange,
label = {
Text(text = label)
},
trailingIcon = {
if (onRemoveClick != null) {
IconButton(onClick = onRemoveClick) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = null,
)
}
}
},
keyboardOptions = KeyboardOptions(
imeAction = if (isLast) ImeAction.Done else ImeAction.Next,
)
)
}
@ColorSchemePreview
@Composable
private fun AddRecipeScreenPreview() {
AppTheme {
AddRecipeScreen(
baseScreenState = previewBaseScreenState(),
state = AddRecipeScreenState(),
onEvent = {},
)
}
}

View File

@@ -0,0 +1,48 @@
package gq.kirmanak.mealient.ui.add
internal sealed interface AddRecipeScreenEvent {
data class RecipeNameInput(
val input: String,
) : AddRecipeScreenEvent
data class RecipeDescriptionInput(
val input: String,
) : AddRecipeScreenEvent
data class RecipeYieldInput(
val input: String,
) : AddRecipeScreenEvent
data object PublicRecipeToggle : AddRecipeScreenEvent
data object DisableCommentsToggle : AddRecipeScreenEvent
data object AddIngredientClick : AddRecipeScreenEvent
data object AddInstructionClick : AddRecipeScreenEvent
data object SaveRecipeClick : AddRecipeScreenEvent
data class IngredientInput(
val ingredientIndex: Int,
val input: String,
) : AddRecipeScreenEvent
data class InstructionInput(
val instructionIndex: Int,
val input: String,
) : AddRecipeScreenEvent
data object ClearInputClick : AddRecipeScreenEvent
data object SnackbarShown : AddRecipeScreenEvent
data class RemoveIngredientClick(
val ingredientIndex: Int,
) : AddRecipeScreenEvent
data class RemoveInstructionClick(
val instructionIndex: Int,
) : AddRecipeScreenEvent
}

View File

@@ -0,0 +1,15 @@
package gq.kirmanak.mealient.ui.add
internal data class AddRecipeScreenState(
val snackbarMessage: AddRecipeSnackbarMessage? = null,
val isLoading: Boolean = false,
val recipeNameInput: String = "",
val recipeDescriptionInput: String = "",
val recipeYieldInput: String = "",
val isPublicRecipe: Boolean = false,
val disableComments: Boolean = false,
val saveButtonEnabled: Boolean = false,
val clearButtonEnabled: Boolean = true,
val ingredients: List<String> = emptyList(),
val instructions: List<String> = emptyList(),
)

View File

@@ -0,0 +1,8 @@
package gq.kirmanak.mealient.ui.add
internal sealed interface AddRecipeSnackbarMessage {
data object Success : AddRecipeSnackbarMessage
data object Error : AddRecipeSnackbarMessage
}

View File

@@ -1,64 +1,210 @@
package gq.kirmanak.mealient.ui.add
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.add.AddRecipeRepo
import gq.kirmanak.mealient.datasource.models.AddRecipeInfo
import gq.kirmanak.mealient.datasource.models.AddRecipeIngredientInfo
import gq.kirmanak.mealient.datasource.models.AddRecipeInstructionInfo
import gq.kirmanak.mealient.datasource.models.AddRecipeSettingsInfo
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class AddRecipeViewModel @Inject constructor(
internal class AddRecipeViewModel @Inject constructor(
private val addRecipeRepo: AddRecipeRepo,
private val logger: Logger,
) : ViewModel() {
private val _addRecipeResultChannel = Channel<Boolean>(Channel.UNLIMITED)
val addRecipeResult: Flow<Boolean> get() = _addRecipeResultChannel.receiveAsFlow()
private val _screenState = MutableStateFlow(AddRecipeScreenState())
val screenState: StateFlow<AddRecipeScreenState> get() = _screenState.asStateFlow()
private val _preservedAddRecipeRequestChannel = Channel<AddRecipeInfo>(Channel.UNLIMITED)
val preservedAddRecipeRequest: Flow<AddRecipeInfo>
get() = _preservedAddRecipeRequestChannel.receiveAsFlow()
fun loadPreservedRequest() {
logger.v { "loadPreservedRequest() called" }
init {
viewModelScope.launch { doLoadPreservedRequest() }
}
private suspend fun doLoadPreservedRequest() {
@VisibleForTesting
suspend fun doLoadPreservedRequest() {
logger.v { "doLoadPreservedRequest() called" }
val request = addRecipeRepo.addRecipeRequestFlow.first()
logger.d { "doLoadPreservedRequest: request = $request" }
_preservedAddRecipeRequestChannel.send(request)
}
fun clear() {
logger.v { "clear() called" }
viewModelScope.launch {
addRecipeRepo.clear()
doLoadPreservedRequest()
_screenState.update { state ->
state.copy(
recipeNameInput = request.name,
recipeDescriptionInput = request.description,
recipeYieldInput = request.recipeYield,
isPublicRecipe = request.settings.public,
disableComments = request.settings.disableComments,
ingredients = request.recipeIngredient.map { it.note },
instructions = request.recipeInstructions.map { it.text },
saveButtonEnabled = request.name.isNotBlank(),
)
}
}
fun preserve(request: AddRecipeInfo) {
logger.v { "preserve() called with: request = $request" }
viewModelScope.launch { addRecipeRepo.preserve(request) }
fun onEvent(event: AddRecipeScreenEvent) {
logger.v { "onEvent() called with: event = $event" }
when (event) {
is AddRecipeScreenEvent.AddIngredientClick -> {
_screenState.update {
it.copy(ingredients = it.ingredients + "")
}
}
is AddRecipeScreenEvent.AddInstructionClick -> {
_screenState.update {
it.copy(instructions = it.instructions + "")
}
}
is AddRecipeScreenEvent.DisableCommentsToggle -> {
_screenState.update {
it.copy(disableComments = !it.disableComments)
}
preserve()
}
is AddRecipeScreenEvent.PublicRecipeToggle -> {
_screenState.update {
it.copy(isPublicRecipe = !it.isPublicRecipe)
}
preserve()
}
is AddRecipeScreenEvent.ClearInputClick -> {
logger.v { "clear() called" }
viewModelScope.launch {
addRecipeRepo.clear()
doLoadPreservedRequest()
}
}
is AddRecipeScreenEvent.IngredientInput -> {
_screenState.update {
val mutableIngredientsList = it.ingredients.toMutableList()
mutableIngredientsList[event.ingredientIndex] = event.input
it.copy(ingredients = mutableIngredientsList)
}
preserve()
}
is AddRecipeScreenEvent.InstructionInput -> {
_screenState.update {
val mutableInstructionsList = it.instructions.toMutableList()
mutableInstructionsList[event.instructionIndex] = event.input
it.copy(instructions = mutableInstructionsList)
}
preserve()
}
is AddRecipeScreenEvent.RecipeDescriptionInput -> {
_screenState.update {
it.copy(recipeDescriptionInput = event.input)
}
preserve()
}
is AddRecipeScreenEvent.RecipeNameInput -> {
_screenState.update {
it.copy(
recipeNameInput = event.input,
saveButtonEnabled = event.input.isNotBlank(),
)
}
preserve()
}
is AddRecipeScreenEvent.RecipeYieldInput -> {
_screenState.update {
it.copy(recipeYieldInput = event.input)
}
preserve()
}
is AddRecipeScreenEvent.SaveRecipeClick -> {
saveRecipe()
}
is AddRecipeScreenEvent.SnackbarShown -> {
_screenState.update {
it.copy(snackbarMessage = null)
}
}
is AddRecipeScreenEvent.RemoveIngredientClick -> {
_screenState.update {
val mutableIngredientsList = it.ingredients.toMutableList()
mutableIngredientsList.removeAt(event.ingredientIndex)
it.copy(ingredients = mutableIngredientsList)
}
preserve()
}
is AddRecipeScreenEvent.RemoveInstructionClick -> {
_screenState.update {
val mutableInstructionsList = it.instructions.toMutableList()
mutableInstructionsList.removeAt(event.instructionIndex)
it.copy(instructions = mutableInstructionsList)
}
preserve()
}
}
}
fun saveRecipe() {
private fun saveRecipe() {
logger.v { "saveRecipe() called" }
_screenState.update {
it.copy(
isLoading = true,
saveButtonEnabled = false,
clearButtonEnabled = false,
)
}
viewModelScope.launch {
val isSuccessful = runCatchingExceptCancel { addRecipeRepo.saveRecipe() }
.fold(onSuccess = { true }, onFailure = { false })
logger.d { "saveRecipe: isSuccessful = $isSuccessful" }
_addRecipeResultChannel.send(isSuccessful)
val isSuccessful = runCatchingExceptCancel { addRecipeRepo.saveRecipe() }.isSuccess
_screenState.update {
it.copy(
isLoading = false,
saveButtonEnabled = true,
clearButtonEnabled = true,
snackbarMessage = if (isSuccessful) {
AddRecipeSnackbarMessage.Success
} else {
AddRecipeSnackbarMessage.Error
}
)
}
}
}
private fun preserve() {
logger.v { "preserve() called" }
viewModelScope.launch {
val request = AddRecipeInfo(
name = screenState.value.recipeNameInput,
description = screenState.value.recipeDescriptionInput,
recipeYield = screenState.value.recipeYieldInput,
recipeIngredient = screenState.value.ingredients.map {
AddRecipeIngredientInfo(it)
},
recipeInstructions = screenState.value.instructions.map {
AddRecipeInstructionInfo(it)
},
settings = AddRecipeSettingsInfo(
public = screenState.value.isPublicRecipe,
disableComments = screenState.value.disableComments,
)
)
addRecipeRepo.preserve(request)
}
}
}

View File

@@ -1,81 +0,0 @@
package gq.kirmanak.mealient.ui.auth
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import by.kirich1409.viewbindingdelegate.viewBinding
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.databinding.FragmentAuthenticationBinding
import gq.kirmanak.mealient.datasource.NetworkError
import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.CheckableMenuItem
import gq.kirmanak.mealient.ui.OperationUiState
import gq.kirmanak.mealient.ui.activity.MainActivityViewModel
import javax.inject.Inject
@AndroidEntryPoint
class AuthenticationFragment : Fragment(R.layout.fragment_authentication) {
private val binding by viewBinding(FragmentAuthenticationBinding::bind)
private val viewModel by viewModels<AuthenticationViewModel>()
private val activityViewModel by activityViewModels<MainActivityViewModel>()
@Inject
lateinit var logger: Logger
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
logger.v { "onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState" }
binding.button.setOnClickListener { onLoginClicked() }
activityViewModel.updateUiState {
it.copy(
navigationVisible = true,
searchVisible = false,
checkedMenuItem = CheckableMenuItem.AddRecipe
)
}
viewModel.uiState.observe(viewLifecycleOwner, ::onUiStateChange)
}
private fun onLoginClicked(): Unit = with(binding) {
logger.v { "onLoginClicked() called" }
val email: String = emailInput.checkIfInputIsEmpty(
inputLayout = emailInputLayout,
lifecycleOwner = viewLifecycleOwner,
stringId = R.string.fragment_authentication_email_input_empty,
logger = logger,
) ?: return
val pass: String = passwordInput.checkIfInputIsEmpty(
inputLayout = passwordInputLayout,
lifecycleOwner = viewLifecycleOwner,
stringId = R.string.fragment_authentication_password_input_empty,
trim = false,
logger = logger,
) ?: return
viewModel.authenticate(email, pass)
}
private fun onUiStateChange(uiState: OperationUiState<Unit>) = with(binding) {
logger.v { "onUiStateChange() called with: authUiState = $uiState" }
if (uiState.isSuccess) {
findNavController().navigateUp()
return
}
passwordInputLayout.error = when (uiState.exceptionOrNull) {
is NetworkError.Unauthorized -> getString(R.string.fragment_authentication_credentials_incorrect)
else -> null
}
uiState.updateButtonState(button)
uiState.updateProgressState(progress)
}
}

View File

@@ -0,0 +1,150 @@
package gq.kirmanak.mealient.ui.auth
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillType
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.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import com.ramcosta.composedestinations.annotation.Destination
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.Dimens
import gq.kirmanak.mealient.ui.components.BaseScreen
import gq.kirmanak.mealient.ui.components.TopProgressIndicator
import gq.kirmanak.mealient.ui.preview.ColorSchemePreview
@Destination
@Composable
internal fun AuthenticationScreen(
navController: NavController,
viewModel: AuthenticationViewModel = hiltViewModel(),
) {
val screenState by viewModel.screenState.collectAsState()
LaunchedEffect(screenState.isSuccessful) {
if (screenState.isSuccessful) {
navController.navigateUp()
}
}
BaseScreen { modifier ->
AuthenticationScreen(
modifier = modifier,
state = screenState,
onEvent = viewModel::onEvent,
)
}
}
@Composable
internal fun AuthenticationScreen(
state: AuthenticationScreenState,
modifier: Modifier = Modifier,
onEvent: (AuthenticationScreenEvent) -> Unit,
) {
TopProgressIndicator(
modifier = modifier
.semantics { testTag = "authentication-screen" },
isLoading = state.isLoading,
) {
Column(
modifier = Modifier
.padding(Dimens.Medium)
.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(Dimens.Medium),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.weight(2f))
EmailInput(
input = state.emailInput,
onEvent = onEvent,
)
PasswordInput(
input = state.passwordInput,
errorText = state.errorText,
isPasswordVisible = state.isPasswordVisible,
onEvent = onEvent,
)
Button(
modifier = Modifier
.semantics { testTag = "login-button" },
enabled = state.buttonEnabled,
onClick = { onEvent(AuthenticationScreenEvent.OnLoginClick) },
) {
Text(
text = stringResource(id = R.string.fragment_authentication_button_login),
)
}
Spacer(modifier = Modifier.weight(8f))
}
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun EmailInput(
input: String,
onEvent: (AuthenticationScreenEvent) -> Unit,
) {
val onValueChange: (String) -> Unit = {
onEvent(AuthenticationScreenEvent.OnEmailInput(it))
}
OutlinedTextField(
modifier = Modifier
.semantics { testTag = "email-input" }
.fillMaxWidth()
.autofill(
autofillTypes = listOf(AutofillType.EmailAddress),
onFill = onValueChange,
),
value = input,
onValueChange = onValueChange,
label = {
Text(
text = stringResource(id = R.string.fragment_authentication_input_hint_email),
)
},
supportingText = {
Text(
text = stringResource(id = R.string.fragment_authentication_email_input_helper_text),
)
},
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next,
)
)
}
@ColorSchemePreview
@Composable
private fun AuthenticationScreenPreview() {
AppTheme {
AuthenticationScreen(
state = AuthenticationScreenState(),
onEvent = {},
)
}
}

View File

@@ -0,0 +1,12 @@
package gq.kirmanak.mealient.ui.auth
internal sealed interface AuthenticationScreenEvent {
data class OnEmailInput(val input: String) : AuthenticationScreenEvent
data class OnPasswordInput(val input: String) : AuthenticationScreenEvent
data object OnLoginClick : AuthenticationScreenEvent
data object TogglePasswordVisibility : AuthenticationScreenEvent
}

View File

@@ -0,0 +1,11 @@
package gq.kirmanak.mealient.ui.auth
internal data class AuthenticationScreenState(
val isLoading: Boolean = false,
val isSuccessful: Boolean = false,
val errorText: String? = null,
val emailInput: String = "",
val passwordInput: String = "",
val buttonEnabled: Boolean = false,
val isPasswordVisible: Boolean = false,
)

View File

@@ -1,33 +1,110 @@
package gq.kirmanak.mealient.ui.auth
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.datasource.NetworkError
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.OperationUiState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.updateAndGet
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class AuthenticationViewModel @Inject constructor(
internal class AuthenticationViewModel @Inject constructor(
private val application: Application,
private val authRepo: AuthRepo,
private val logger: Logger,
) : ViewModel() {
private val _uiState = MutableLiveData<OperationUiState<Unit>>(OperationUiState.Initial())
val uiState: LiveData<OperationUiState<Unit>> get() = _uiState
private val _screenState = MutableStateFlow(AuthenticationScreenState())
val screenState = _screenState.asStateFlow()
fun authenticate(email: String, password: String) {
logger.v { "authenticate() called" }
_uiState.value = OperationUiState.Progress()
viewModelScope.launch {
val result = runCatchingExceptCancel { authRepo.authenticate(email, password) }
logger.d { "Authentication result = $result" }
_uiState.value = OperationUiState.fromResult(result)
fun onEvent(event: AuthenticationScreenEvent) {
logger.v { "onEvent() called with: event = $event" }
when (event) {
is AuthenticationScreenEvent.OnLoginClick -> {
onLoginClick()
}
is AuthenticationScreenEvent.OnEmailInput -> {
onEmailInput(event.input)
}
is AuthenticationScreenEvent.OnPasswordInput -> {
onPasswordInput(event.input)
}
AuthenticationScreenEvent.TogglePasswordVisibility -> {
togglePasswordVisibility()
}
}
}
private fun togglePasswordVisibility() {
_screenState.update {
it.copy(isPasswordVisible = !it.isPasswordVisible)
}
}
private fun onPasswordInput(passwordInput: String) {
_screenState.update {
it.copy(
passwordInput = passwordInput,
buttonEnabled = passwordInput.isNotEmpty() && it.emailInput.isNotEmpty(),
)
}
}
private fun onEmailInput(emailInput: String) {
_screenState.update {
it.copy(
emailInput = emailInput.trim(),
buttonEnabled = emailInput.isNotEmpty() && it.passwordInput.isNotEmpty(),
)
}
}
private fun onLoginClick() {
val screenState = _screenState.updateAndGet {
it.copy(
isLoading = true,
errorText = null,
buttonEnabled = false,
)
}
viewModelScope.launch {
val result = runCatchingExceptCancel {
authRepo.authenticate(
email = screenState.emailInput,
password = screenState.passwordInput
)
}
logger.d { "onLoginClick: result = $result" }
val errorText = result.fold(
onSuccess = { null },
onFailure = {
when (it) {
is NetworkError.Unauthorized -> application.getString(R.string.fragment_authentication_credentials_incorrect)
else -> it.message
}
}
)
_screenState.update {
it.copy(
isLoading = false,
isSuccessful = result.isSuccess,
errorText = errorText,
buttonEnabled = true,
)
}
}
}
}

View File

@@ -0,0 +1,37 @@
package gq.kirmanak.mealient.ui.auth
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillNode
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.composed
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalAutofill
import androidx.compose.ui.platform.LocalAutofillTree
@OptIn(ExperimentalComposeUiApi::class)
fun Modifier.autofill(
autofillTypes: List<AutofillType>,
onFill: ((String) -> Unit),
) = composed {
val autofillNode = AutofillNode(
autofillTypes = autofillTypes,
onFill = onFill,
)
LocalAutofillTree.current += autofillNode
val autofill = LocalAutofill.current
onGloballyPositioned {
autofillNode.boundingBox = it.boundsInWindow()
}.onFocusChanged { focusState ->
autofill?.run {
if (focusState.isFocused) {
requestAutofillForNode(autofillNode)
} else {
cancelAutofillForNode(autofillNode)
}
}
}
}

View File

@@ -0,0 +1,114 @@
package gq.kirmanak.mealient.ui.auth
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillType
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.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import gq.kirmanak.mealient.R
@OptIn(ExperimentalComposeUiApi::class)
@Composable
internal fun PasswordInput(
input: String,
errorText: String?,
isPasswordVisible: Boolean,
onEvent: (AuthenticationScreenEvent) -> Unit,
) {
val isError = errorText != null
val onValueChange: (String) -> Unit = {
onEvent(AuthenticationScreenEvent.OnPasswordInput(it))
}
OutlinedTextField(
modifier = Modifier
.semantics { testTag = "password-input" }
.fillMaxWidth()
.autofill(
autofillTypes = listOf(AutofillType.Password),
onFill = onValueChange,
),
value = input,
onValueChange = onValueChange,
label = {
Text(
text = stringResource(id = R.string.fragment_authentication_input_hint_password),
)
},
trailingIcon = {
PasswordTrailingIcon(
isError = isError,
isPasswordVisible = isPasswordVisible,
onEvent = onEvent,
)
},
isError = isError,
supportingText = {
Text(
text = errorText
?: stringResource(id = R.string.fragment_authentication_password_input_helper_text),
)
},
visualTransformation = if (isPasswordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = {
defaultKeyboardAction(ImeAction.Done)
onEvent(AuthenticationScreenEvent.OnLoginClick)
},
)
)
}
@Composable
private fun PasswordTrailingIcon(
isError: Boolean,
isPasswordVisible: Boolean,
onEvent: (AuthenticationScreenEvent) -> Unit,
) {
val image = if (isError) {
Icons.Default.Warning
} else if (isPasswordVisible) {
Icons.Default.VisibilityOff
} else {
Icons.Default.Visibility
}
if (isError) {
Icon(
imageVector = image,
contentDescription = null,
)
} else {
IconButton(
onClick = {
onEvent(AuthenticationScreenEvent.TogglePasswordVisibility)
},
) {
Icon(
imageVector = image,
contentDescription = null,
)
}
}
}

View File

@@ -1,113 +0,0 @@
package gq.kirmanak.mealient.ui.baseurl
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import by.kirich1409.viewbindingdelegate.viewBinding
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.databinding.FragmentBaseUrlBinding
import gq.kirmanak.mealient.datasource.CertificateCombinedException
import gq.kirmanak.mealient.datasource.NetworkError
import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
import gq.kirmanak.mealient.extensions.collectWhenResumed
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.CheckableMenuItem
import gq.kirmanak.mealient.ui.OperationUiState
import gq.kirmanak.mealient.ui.activity.MainActivityViewModel
import gq.kirmanak.mealient.ui.baseurl.BaseURLFragmentDirections.Companion.actionBaseURLFragmentToRecipesListFragment
import java.security.cert.X509Certificate
import javax.inject.Inject
@AndroidEntryPoint
class BaseURLFragment : Fragment(R.layout.fragment_base_url) {
private val binding by viewBinding(FragmentBaseUrlBinding::bind)
private val viewModel by viewModels<BaseURLViewModel>()
private val activityViewModel by activityViewModels<MainActivityViewModel>()
private val args by navArgs<BaseURLFragmentArgs>()
@Inject
lateinit var logger: Logger
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
logger.v { "onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState" }
binding.button.setOnClickListener(::onProceedClick)
viewModel.uiState.observe(viewLifecycleOwner, ::onUiStateChange)
collectWhenResumed(viewModel.invalidCertificatesFlow, ::onInvalidCertificate)
activityViewModel.updateUiState {
it.copy(
navigationVisible = !args.isOnboarding,
searchVisible = false,
checkedMenuItem = CheckableMenuItem.ChangeUrl,
)
}
}
private fun onInvalidCertificate(certificate: X509Certificate) {
logger.v { "onInvalidCertificate() called with: certificate = $certificate" }
val dialogMessage = getString(
R.string.fragment_base_url_invalid_certificate_message,
certificate.issuerDN.toString(),
certificate.subjectDN.toString(),
certificate.notBefore.toString(),
certificate.notAfter.toString(),
)
val dialog = AlertDialog.Builder(requireContext())
.setTitle(R.string.fragment_base_url_invalid_certificate_title)
.setMessage(dialogMessage)
.setPositiveButton(R.string.fragment_base_url_invalid_certificate_accept) { _, _ ->
viewModel.acceptInvalidCertificate(certificate)
saveEnteredUrl()
}.setNegativeButton(R.string.fragment_base_url_invalid_certificate_deny) { _, _ ->
// Do nothing, let the user enter another address or try again
}
.create()
dialog.show()
}
private fun onProceedClick(view: View) {
logger.v { "onProceedClick() called with: view = $view" }
saveEnteredUrl()
}
private fun saveEnteredUrl() {
logger.v { "saveEnteredUrl() called" }
val url = binding.urlInput.checkIfInputIsEmpty(
inputLayout = binding.urlInputLayout,
lifecycleOwner = viewLifecycleOwner,
stringId = R.string.fragment_baseurl_url_input_empty,
logger = logger,
) ?: return
viewModel.saveBaseUrl(url)
}
private fun onUiStateChange(uiState: OperationUiState<Unit>) = with(binding) {
logger.v { "onUiStateChange() called with: uiState = $uiState" }
if (uiState.isSuccess) {
findNavController().navigate(actionBaseURLFragmentToRecipesListFragment())
return
}
urlInputLayout.error = when (val exception = uiState.exceptionOrNull) {
is NetworkError.NoServerConnection -> getString(R.string.fragment_base_url_no_connection)
is NetworkError.NotMealie -> getString(R.string.fragment_base_url_unexpected_response)
is NetworkError.MalformedUrl -> {
val cause = exception.cause?.message ?: exception.message
getString(R.string.fragment_base_url_malformed_url, cause)
}
is CertificateCombinedException -> getString(R.string.fragment_base_url_invalid_certificate_title)
null -> null
else -> getString(R.string.fragment_base_url_unknown_error)
}
uiState.updateButtonState(button)
uiState.updateProgressState(progress)
}
}

View File

@@ -0,0 +1,205 @@
package gq.kirmanak.mealient.ui.baseurl
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import com.ramcosta.composedestinations.annotation.Destination
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.Dimens
import gq.kirmanak.mealient.ui.components.BaseScreen
import gq.kirmanak.mealient.ui.components.BaseScreenState
import gq.kirmanak.mealient.ui.components.BaseScreenWithNavigation
import gq.kirmanak.mealient.ui.components.TopProgressIndicator
import gq.kirmanak.mealient.ui.components.previewBaseScreenState
import gq.kirmanak.mealient.ui.preview.ColorSchemePreview
@Destination
@Composable
internal fun BaseURLScreen(
navController: NavController,
baseScreenState: BaseScreenState,
viewModel: BaseURLViewModel = hiltViewModel(),
) {
val screenState by viewModel.screenState.collectAsState()
LaunchedEffect(screenState.isConfigured) {
if (screenState.isConfigured) {
navController.navigateUp()
}
}
BaseURLScreen(
state = screenState,
baseScreenState = baseScreenState,
onEvent = viewModel::onEvent,
)
}
@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,
)
}
}
@Composable
private fun BaseURLScreen(
state: BaseURLScreenState,
modifier: Modifier = Modifier,
onEvent: (BaseURLScreenEvent) -> Unit,
) {
if (state.invalidCertificateDialogState != null) {
InvalidCertificateDialog(
state = state.invalidCertificateDialogState,
onEvent = onEvent,
)
}
TopProgressIndicator(
modifier = modifier
.semantics { testTag = "base-url-screen" },
isLoading = state.isLoading,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(Dimens.Large),
verticalArrangement = Arrangement.spacedBy(Dimens.Large),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.weight(3f))
UrlInputField(
input = state.userInput,
errorText = state.errorText,
onEvent = onEvent,
isError = state.errorText != null,
)
Button(
modifier = Modifier
.semantics { testTag = "proceed-button" },
onClick = { onEvent(BaseURLScreenEvent.OnProceedClick) },
enabled = state.isButtonEnabled,
) {
Text(
modifier = Modifier
.semantics { testTag = "proceed-button-text" },
text = stringResource(id = R.string.fragment_base_url_save),
)
}
Spacer(modifier = Modifier.weight(7f))
}
}
}
@Composable
private fun UrlInputField(
input: String,
errorText: String?,
onEvent: (BaseURLScreenEvent) -> Unit,
isError: Boolean,
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.semantics { testTag = "url-input-field" },
value = input,
isError = isError,
label = {
Text(
modifier = Modifier
.semantics { testTag = "url-input-label" },
text = stringResource(id = R.string.fragment_authentication_input_hint_url),
)
},
supportingText = {
Text(
text = errorText
?: stringResource(id = R.string.fragment_base_url_url_input_helper_text),
)
},
onValueChange = {
onEvent(BaseURLScreenEvent.OnUserInput(it))
},
trailingIcon = {
if (isError) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
)
}
},
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = {
defaultKeyboardAction(ImeAction.Done)
onEvent(BaseURLScreenEvent.OnProceedClick)
},
)
)
}
@ColorSchemePreview
@Composable
private fun BaseURLScreenPreview() {
AppTheme {
BaseURLScreen(
state = BaseURLScreenState(
userInput = "https://www.google.com",
errorText = null,
isButtonEnabled = true,
isLoading = true,
isNavigationEnabled = false,
),
baseScreenState = previewBaseScreenState(),
onEvent = {},
)
}
}

View File

@@ -0,0 +1,16 @@
package gq.kirmanak.mealient.ui.baseurl
import java.security.cert.X509Certificate
internal sealed interface BaseURLScreenEvent {
data object OnProceedClick : BaseURLScreenEvent
data class OnUserInput(val input: String) : BaseURLScreenEvent
data object OnInvalidCertificateDialogDismiss : BaseURLScreenEvent
data class OnInvalidCertificateDialogAccept(
val certificate: X509Certificate,
) : BaseURLScreenEvent
}

View File

@@ -0,0 +1,17 @@
package gq.kirmanak.mealient.ui.baseurl
internal data class BaseURLScreenState(
val isConfigured: Boolean = false,
val userInput: String = "",
val errorText: String? = null,
val isButtonEnabled: Boolean = false,
val isLoading: Boolean = false,
val invalidCertificateDialogState: InvalidCertificateDialogState? = null,
val isNavigationEnabled: Boolean = true,
) {
data class InvalidCertificateDialogState(
val message: String,
val onAcceptEvent: BaseURLScreenEvent,
)
}

View File

@@ -1,10 +1,10 @@
package gq.kirmanak.mealient.ui.baseurl
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.data.baseurl.impl.BaseUrlLogRedactor
@@ -14,32 +14,50 @@ import gq.kirmanak.mealient.datasource.NetworkError
import gq.kirmanak.mealient.datasource.TrustedCertificatesStore
import gq.kirmanak.mealient.datasource.findCauseAsInstanceOf
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.OperationUiState
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.security.cert.X509Certificate
import javax.inject.Inject
@HiltViewModel
class BaseURLViewModel @Inject constructor(
internal class BaseURLViewModel @Inject constructor(
private val application: Application,
private val serverInfoRepo: ServerInfoRepo,
private val authRepo: AuthRepo,
private val recipeRepo: RecipeRepo,
private val logger: Logger,
private val trustedCertificatesStore: TrustedCertificatesStore,
private val baseUrlLogRedactor: BaseUrlLogRedactor,
) : ViewModel() {
) : AndroidViewModel(application) {
private val _uiState = MutableLiveData<OperationUiState<Unit>>(OperationUiState.Initial())
val uiState: LiveData<OperationUiState<Unit>> get() = _uiState
private val _screenState = MutableStateFlow(BaseURLScreenState())
val screenState = _screenState.asStateFlow()
private val invalidCertificatesChannel = Channel<X509Certificate>(Channel.UNLIMITED)
val invalidCertificatesFlow = invalidCertificatesChannel.receiveAsFlow()
init {
checkIfNavigationIsAllowed()
}
private fun checkIfNavigationIsAllowed() {
logger.v { "checkIfNavigationIsAllowed() called" }
viewModelScope.launch {
val allowed = serverInfoRepo.getUrl() != null
logger.d { "checkIfNavigationIsAllowed: allowed = $allowed" }
_screenState.update { it.copy(isNavigationEnabled = allowed) }
}
}
fun saveBaseUrl(baseURL: String) {
logger.v { "saveBaseUrl() called" }
_uiState.value = OperationUiState.Progress()
_screenState.update {
it.copy(
isLoading = true,
errorText = null,
invalidCertificateDialogState = null,
isButtonEnabled = false,
)
}
viewModelScope.launch { checkBaseURL(baseURL) }
}
@@ -55,7 +73,7 @@ class BaseURLViewModel @Inject constructor(
logger.d { "checkBaseURL: Created URL = \"$url\", with prefix = \"$urlWithPrefix\"" }
if (url == serverInfoRepo.getUrl()) {
logger.d { "checkBaseURL: new URL matches current" }
_uiState.value = OperationUiState.fromResult(Result.success(Unit))
displayCheckUrlSuccess()
return
}
@@ -63,7 +81,6 @@ class BaseURLViewModel @Inject constructor(
logger.e(it) { "checkBaseURL: trying to recover, had prefix = $hasPrefix" }
val certificateError = it.findCauseAsInstanceOf<CertificateCombinedException>()
if (certificateError != null) {
invalidCertificatesChannel.send(certificateError.serverCert)
throw certificateError
} else if (hasPrefix || it is NetworkError.NotMealie) {
throw it
@@ -79,11 +96,113 @@ class BaseURLViewModel @Inject constructor(
}
logger.i { "checkBaseURL: result is $result" }
_uiState.value = OperationUiState.fromResult(result)
result.fold(
onSuccess = { displayCheckUrlSuccess() },
onFailure = { displayCheckUrlError(it) },
)
}
fun acceptInvalidCertificate(certificate: X509Certificate) {
private fun displayCheckUrlSuccess() {
logger.v { "displayCheckUrlSuccess() called" }
_screenState.update {
it.copy(
isConfigured = true,
isLoading = false,
isButtonEnabled = true,
errorText = null,
invalidCertificateDialogState = null,
)
}
}
private fun displayCheckUrlError(exception: Throwable) {
logger.v { "displayCheckUrlError() called with: exception = $exception" }
val errorText = getErrorText(exception)
val invalidCertificateDialogState = if (exception is CertificateCombinedException) {
buildInvalidCertificateDialog(exception)
} else {
null
}
_screenState.update {
it.copy(
errorText = errorText,
isButtonEnabled = true,
isLoading = false,
invalidCertificateDialogState = invalidCertificateDialogState,
)
}
}
private fun buildInvalidCertificateDialog(
exception: CertificateCombinedException,
): BaseURLScreenState.InvalidCertificateDialogState {
logger.v { "buildInvalidCertificateDialog() called with: exception = $exception" }
val certificate = exception.serverCert
val message = application.getString(
R.string.fragment_base_url_invalid_certificate_message,
certificate.issuerDN.toString(),
certificate.subjectDN.toString(),
certificate.notBefore.toString(),
certificate.notAfter.toString(),
)
return BaseURLScreenState.InvalidCertificateDialogState(
message = message,
onAcceptEvent = BaseURLScreenEvent.OnInvalidCertificateDialogAccept(
certificate = exception.serverCert,
),
)
}
private fun getErrorText(throwable: Throwable): String {
logger.v { "getErrorText() called with: throwable = $throwable" }
return when (throwable) {
is NetworkError.NoServerConnection -> application.getString(R.string.fragment_base_url_no_connection)
is NetworkError.NotMealie -> application.getString(R.string.fragment_base_url_unexpected_response)
is CertificateCombinedException -> application.getString(R.string.fragment_base_url_invalid_certificate_title)
is NetworkError.MalformedUrl -> {
val cause = throwable.cause?.message ?: throwable.message
application.getString(R.string.fragment_base_url_malformed_url, cause)
}
else -> application.getString(R.string.fragment_base_url_unknown_error)
}
}
private fun acceptInvalidCertificate(certificate: X509Certificate) {
logger.v { "acceptInvalidCertificate() called with: certificate = $certificate" }
trustedCertificatesStore.addTrustedCertificate(certificate)
}
fun onEvent(event: BaseURLScreenEvent) {
logger.v { "onEvent() called with: event = $event" }
when (event) {
is BaseURLScreenEvent.OnProceedClick -> {
saveBaseUrl(_screenState.value.userInput)
}
is BaseURLScreenEvent.OnUserInput -> {
_screenState.update {
it.copy(
userInput = event.input.trim(),
isButtonEnabled = event.input.isNotEmpty(),
)
}
}
is BaseURLScreenEvent.OnInvalidCertificateDialogAccept -> {
_screenState.update {
it.copy(
invalidCertificateDialogState = null,
errorText = null,
)
}
acceptInvalidCertificate(event.certificate)
}
is BaseURLScreenEvent.OnInvalidCertificateDialogDismiss -> {
_screenState.update { it.copy(invalidCertificateDialogState = null) }
}
}
}
}

View File

@@ -0,0 +1,57 @@
package gq.kirmanak.mealient.ui.baseurl
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.preview.ColorSchemePreview
@Composable
internal fun InvalidCertificateDialog(
state: BaseURLScreenState.InvalidCertificateDialogState,
onEvent: (BaseURLScreenEvent) -> Unit,
) {
val onDismiss = {
onEvent(BaseURLScreenEvent.OnInvalidCertificateDialogDismiss)
}
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(
onClick = { onEvent(state.onAcceptEvent) },
) {
Text(text = stringResource(id = R.string.fragment_base_url_invalid_certificate_accept))
}
},
dismissButton = {
TextButton(
onClick = onDismiss,
) {
Text(text = stringResource(id = R.string.fragment_base_url_invalid_certificate_deny))
}
},
title = {
Text(text = stringResource(id = R.string.fragment_base_url_invalid_certificate_title))
},
text = {
Text(text = state.message)
},
)
}
@ColorSchemePreview
@Composable
private fun InvalidCertificateDialogPreview() {
AppTheme {
InvalidCertificateDialog(
state = BaseURLScreenState.InvalidCertificateDialogState(
message = "This is a preview message",
onAcceptEvent = BaseURLScreenEvent.OnInvalidCertificateDialogDismiss,
),
onEvent = {},
)
}
}

View File

@@ -1,68 +0,0 @@
package gq.kirmanak.mealient.ui.disclaimer
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import by.kirich1409.viewbindingdelegate.viewBinding
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.databinding.FragmentDisclaimerBinding
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.activity.MainActivityViewModel
import gq.kirmanak.mealient.ui.disclaimer.DisclaimerFragmentDirections.Companion.actionDisclaimerFragmentToBaseURLFragment
import javax.inject.Inject
@AndroidEntryPoint
class DisclaimerFragment : Fragment(R.layout.fragment_disclaimer) {
private val binding by viewBinding(FragmentDisclaimerBinding::bind)
private val viewModel by viewModels<DisclaimerViewModel>()
private val activityViewModel by activityViewModels<MainActivityViewModel>()
@Inject
lateinit var logger: Logger
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
logger.v { "onCreate() called with: savedInstanceState = $savedInstanceState" }
viewModel.isAccepted.observe(this, ::onAcceptStateChange)
}
private fun onAcceptStateChange(isAccepted: Boolean) {
logger.v { "onAcceptStateChange() called with: isAccepted = $isAccepted" }
if (isAccepted) navigateNext()
}
private fun navigateNext() {
logger.v { "navigateNext() called" }
findNavController().navigate(actionDisclaimerFragmentToBaseURLFragment(true))
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
logger.v { "onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState" }
binding.okay.setOnClickListener {
logger.v { "onViewCreated: okay clicked" }
viewModel.acceptDisclaimer()
}
viewModel.okayCountDown.observe(viewLifecycleOwner) {
logger.d { "onViewCreated: new count $it" }
binding.okay.text = if (it > 0) resources.getQuantityString(
R.plurals.fragment_disclaimer_button_okay_timer, it, it
) else getString(R.string.fragment_disclaimer_button_okay)
binding.okay.isClickable = it == 0
binding.okay.isEnabled = it == 0
}
viewModel.startCountDown()
activityViewModel.updateUiState {
it.copy(
navigationVisible = false,
searchVisible = false,
checkedMenuItem = null
)
}
}
}

View File

@@ -0,0 +1,116 @@
package gq.kirmanak.mealient.ui.disclaimer
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import com.ramcosta.composedestinations.annotation.Destination
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.Dimens
import gq.kirmanak.mealient.ui.components.BaseScreen
import gq.kirmanak.mealient.ui.preview.ColorSchemePreview
@Destination
@Composable
internal fun DisclaimerScreen(
navController: NavController,
viewModel: DisclaimerViewModel = hiltViewModel(),
) {
val screenState by viewModel.screenState.collectAsState()
val isAccepted by viewModel.isAcceptedState.collectAsState()
LaunchedEffect(isAccepted) {
if (isAccepted) {
navController.navigateUp()
}
}
LaunchedEffect(Unit) {
viewModel.startCountDown()
}
BaseScreen { modifier ->
DisclaimerScreen(
modifier = modifier,
state = screenState,
onButtonClick = viewModel::acceptDisclaimer,
)
}
}
@Composable
internal fun DisclaimerScreen(
state: DisclaimerScreenState,
modifier: Modifier = Modifier,
onButtonClick: () -> Unit,
) {
Column(
modifier = modifier
.padding(Dimens.Large)
.semantics { testTag = "disclaimer-screen" },
verticalArrangement = Arrangement.spacedBy(Dimens.Large),
horizontalAlignment = Alignment.CenterHorizontally,
) {
ElevatedCard {
Text(
modifier = Modifier
.padding(Dimens.Medium)
.semantics { testTag = "disclaimer-text" },
text = stringResource(id = R.string.fragment_disclaimer_main_text),
style = MaterialTheme.typography.bodyMedium,
)
}
Button(
modifier = Modifier
.semantics { testTag = "okay-button" },
onClick = onButtonClick,
enabled = state.isCountDownOver,
) {
val text = if (state.isCountDownOver) {
stringResource(R.string.fragment_disclaimer_button_okay)
} else {
pluralStringResource(
R.plurals.fragment_disclaimer_button_okay_timer,
state.countDown,
state.countDown,
)
}
Text(
modifier = Modifier
.semantics { testTag = "okay-button-text" },
text = text,
)
}
}
}
@ColorSchemePreview
@Composable
private fun DisclaimerScreenPreview() {
AppTheme {
DisclaimerScreen(
state = DisclaimerScreenState(
isCountDownOver = false,
countDown = 5,
),
onButtonClick = {},
)
}
}

View File

@@ -0,0 +1,6 @@
package gq.kirmanak.mealient.ui.disclaimer
internal data class DisclaimerScreenState(
val isCountDownOver: Boolean,
val countDown: Int,
)

View File

@@ -1,31 +1,56 @@
package gq.kirmanak.mealient.ui.disclaimer
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.*
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.launch
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@HiltViewModel
class DisclaimerViewModel @Inject constructor(
internal class DisclaimerViewModel @Inject constructor(
private val disclaimerStorage: DisclaimerStorage,
private val logger: Logger,
) : ViewModel() {
val isAccepted: LiveData<Boolean>
get() = disclaimerStorage.isDisclaimerAcceptedFlow.asLiveData()
private val _okayCountDown = MutableLiveData(FULL_COUNT_DOWN_SEC)
val okayCountDown: LiveData<Int> = _okayCountDown
private var isCountDownStarted = false
private val okayCountDown = MutableStateFlow(FULL_COUNT_DOWN_SEC)
val screenState: StateFlow<DisclaimerScreenState> = okayCountDown
.map(::countDownToScreenState)
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = countDownToScreenState(okayCountDown.value)
)
val isAcceptedState: StateFlow<Boolean>
get() = disclaimerStorage
.isDisclaimerAcceptedFlow
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
private fun countDownToScreenState(countDown: Int): DisclaimerScreenState {
logger.v { "countDownToScreenState() called with: countDown = $countDown" }
return DisclaimerScreenState(
isCountDownOver = countDown == 0,
countDown = countDown,
)
}
fun acceptDisclaimer() {
logger.v { "acceptDisclaimer() called" }
viewModelScope.launch { disclaimerStorage.acceptDisclaimer() }
@@ -37,7 +62,7 @@ class DisclaimerViewModel @Inject constructor(
isCountDownStarted = true
tickerFlow(COUNT_DOWN_TICK_PERIOD_SEC.toLong(), TimeUnit.SECONDS)
.take(FULL_COUNT_DOWN_SEC - COUNT_DOWN_TICK_PERIOD_SEC + 1)
.onEach { _okayCountDown.value = FULL_COUNT_DOWN_SEC - it }
.onEach { okayCountDown.value = FULL_COUNT_DOWN_SEC - it }
.launchIn(viewModelScope)
}

View File

@@ -1,37 +0,0 @@
package gq.kirmanak.mealient.ui.recipes.info
import android.os.Bundle
import android.view.View
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.ui.BaseComposeFragment
import gq.kirmanak.mealient.ui.CheckableMenuItem
import gq.kirmanak.mealient.ui.activity.MainActivityViewModel
@AndroidEntryPoint
class RecipeInfoFragment : BaseComposeFragment() {
private val viewModel by viewModels<RecipeInfoViewModel>()
private val activityViewModel by activityViewModels<MainActivityViewModel>()
@Composable
override fun Screen() {
val uiState by viewModel.uiState.collectAsState()
RecipeScreen(uiState = uiState)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
activityViewModel.updateUiState {
it.copy(
navigationVisible = false,
searchVisible = false,
checkedMenuItem = CheckableMenuItem.RecipesList,
)
}
}
}

View File

@@ -10,6 +10,7 @@ import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeInstructionEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeWithSummaryAndIngredientsAndInstructions
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.navArgs
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flow
@@ -17,14 +18,15 @@ import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@HiltViewModel
class RecipeInfoViewModel @Inject constructor(
internal class RecipeInfoViewModel @Inject constructor(
private val recipeRepo: RecipeRepo,
private val logger: Logger,
private val recipeImageUrlProvider: RecipeImageUrlProvider,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val args = RecipeInfoFragmentArgs.fromSavedStateHandle(savedStateHandle)
private val args = savedStateHandle.navArgs<RecipeScreenArgs>()
private val _uiState = flow {
logger.v { "Initializing UI state with args = $args" }
val recipeInfo = recipeRepo.loadRecipeInfo(args.recipeId)

View File

@@ -2,53 +2,71 @@ package gq.kirmanak.mealient.ui.recipes.info
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Scaffold
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.hilt.navigation.compose.hiltViewModel
import com.ramcosta.composedestinations.annotation.Destination
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.Dimens
import gq.kirmanak.mealient.ui.components.BaseScreen
import gq.kirmanak.mealient.ui.preview.ColorSchemePreview
@OptIn(ExperimentalLayoutApi::class)
data class RecipeScreenArgs(
val recipeId: String,
)
@Destination(
navArgsDelegate = RecipeScreenArgs::class,
)
@Composable
fun RecipeScreen(
uiState: RecipeInfoUiState,
internal fun RecipeScreen(
viewModel: RecipeInfoViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsState()
BaseScreen { modifier ->
RecipeScreen(
modifier = modifier,
state = state,
)
}
}
@Composable
private fun RecipeScreen(
state: RecipeInfoUiState,
modifier: Modifier = Modifier,
) {
KeepScreenOn()
Scaffold { padding ->
Column(
modifier = Modifier
.verticalScroll(
state = rememberScrollState(),
)
.padding(padding)
.consumeWindowInsets(padding),
verticalArrangement = Arrangement.spacedBy(Dimens.Small, Alignment.Top),
) {
HeaderSection(
imageUrl = uiState.imageUrl,
title = uiState.title,
description = uiState.description,
Column(
modifier = modifier
.verticalScroll(
state = rememberScrollState(),
),
verticalArrangement = Arrangement.spacedBy(Dimens.Small, Alignment.Top),
) {
HeaderSection(
imageUrl = state.imageUrl,
title = state.title,
description = state.description,
)
if (state.showIngredients) {
IngredientsSection(
ingredients = state.recipeIngredients,
)
}
if (uiState.showIngredients) {
IngredientsSection(
ingredients = uiState.recipeIngredients,
)
}
if (uiState.showInstructions) {
InstructionsSection(
instructions = uiState.recipeInstructions,
)
}
if (state.showInstructions) {
InstructionsSection(
instructions = state.recipeInstructions,
)
}
}
}
@@ -58,7 +76,7 @@ fun RecipeScreen(
private fun RecipeScreenPreview() {
AppTheme {
RecipeScreen(
uiState = RecipeInfoUiState(
state = RecipeInfoUiState(
showIngredients = true,
showInstructions = true,
summaryEntity = SUMMARY_ENTITY,

View File

@@ -8,6 +8,10 @@ import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -103,7 +107,7 @@ private fun RecipeHeader(
onClick = onDeleteClick,
) {
Icon(
painter = painterResource(id = R.drawable.ic_delete),
imageVector = Icons.Default.Delete,
contentDescription = stringResource(id = R.string.view_holder_recipe_delete_content_description),
)
}
@@ -112,15 +116,17 @@ private fun RecipeHeader(
IconButton(
onClick = onFavoriteClick,
) {
val resource = if (recipe.entity.isFavorite) {
R.drawable.ic_favorite_filled
} else {
R.drawable.ic_favorite_unfilled
}
Icon(
painter = painterResource(id = resource),
contentDescription = stringResource(id = R.string.view_holder_recipe_favorite_content_description),
imageVector = if (recipe.entity.isFavorite) {
Icons.Default.Favorite
} else {
Icons.Default.FavoriteBorder
},
contentDescription = if (recipe.entity.isFavorite) {
stringResource(id = R.string.view_holder_recipe_favorite_content_description)
} else {
stringResource(id = R.string.view_holder_recipe_non_favorite_content_description)
},
)
}
}

View File

@@ -1,15 +1,18 @@
package gq.kirmanak.mealient.ui.recipes.list
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets
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.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DrawerState
import androidx.compose.material3.DrawerValue
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
@@ -17,43 +20,83 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import androidx.paging.LoadState
import androidx.paging.PagingData
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemContentType
import androidx.paging.compose.itemKey
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.navigate
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.Dimens
import gq.kirmanak.mealient.ui.components.BaseScreenState
import gq.kirmanak.mealient.ui.components.BaseScreenWithNavigation
import gq.kirmanak.mealient.ui.components.CenteredProgressIndicator
import gq.kirmanak.mealient.ui.components.LazyPagingColumnPullRefresh
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import gq.kirmanak.mealient.ui.components.OpenDrawerIconButton
import gq.kirmanak.mealient.ui.destinations.RecipeScreenDestination
import gq.kirmanak.mealient.ui.preview.ColorSchemePreview
@OptIn(ExperimentalLayoutApi::class)
@Destination
@Composable
internal fun RecipesList(
recipesFlow: Flow<PagingData<RecipeListItemState>>,
onDeleteClick: (RecipeListItemState) -> Unit,
onFavoriteClick: (RecipeListItemState) -> Unit,
onItemClick: (RecipeListItemState) -> Unit,
onSnackbarShown: () -> Unit,
snackbarMessageState: StateFlow<RecipeListSnackbar?>,
navController: NavController,
baseScreenState: BaseScreenState,
viewModel: RecipesListViewModel = hiltViewModel(),
) {
val recipes: LazyPagingItems<RecipeListItemState> = recipesFlow.collectAsLazyPagingItems()
val state = viewModel.screenState.collectAsState()
val stateValue = state.value
LaunchedEffect(stateValue.recipeIdToOpen) {
if (stateValue.recipeIdToOpen != null) {
navController.navigate(RecipeScreenDestination(stateValue.recipeIdToOpen))
viewModel.onEvent(RecipeListEvent.RecipeOpened)
}
}
RecipesList(
state = stateValue,
baseScreenState = baseScreenState,
onEvent = viewModel::onEvent,
)
}
@Composable
private fun RecipesList(
state: RecipeListState,
baseScreenState: BaseScreenState,
onEvent: (RecipeListEvent) -> Unit,
) {
val recipes: LazyPagingItems<RecipeListItemState> =
state.pagingDataRecipeState.collectAsLazyPagingItems()
val isRefreshing = recipes.loadState.refresh is LoadState.Loading
var itemToDelete: RecipeListItemState? by remember { mutableStateOf(null) }
val snackbarHostState = remember { SnackbarHostState() }
val snackbar: RecipeListSnackbar? by snackbarMessageState.collectAsState()
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
snackbar?.message?.let { message ->
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
BaseScreenWithNavigation(
baseScreenState = baseScreenState,
drawerState = drawerState,
topAppBar = {
RecipesTopAppBar(
searchQuery = state.searchQuery,
onValueChanged = { onEvent(RecipeListEvent.SearchQueryChanged(it)) },
drawerState = drawerState,
)
},
snackbarHostState = snackbarHostState,
) { modifier ->
state.snackbarState?.message?.let { message ->
LaunchedEffect(message) {
snackbarHostState.showSnackbar(message)
onSnackbarShown()
onEvent(RecipeListEvent.SnackbarShown)
}
} ?: run {
snackbarHostState.currentSnackbarData?.dismiss()
@@ -63,36 +106,33 @@ internal fun RecipesList(
ConfirmDeleteDialog(
onDismissRequest = { itemToDelete = null },
onConfirm = {
onDeleteClick(item)
onEvent(RecipeListEvent.DeleteConfirmed(item))
itemToDelete = null
},
item = item,
)
}
val innerModifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
when {
recipes.itemCount != 0 -> {
RecipesListData(
modifier = innerModifier,
modifier = modifier,
recipes = recipes,
onDeleteClick = { itemToDelete = it },
onFavoriteClick = onFavoriteClick,
onItemClick = onItemClick
onFavoriteClick = { onEvent(RecipeListEvent.FavoriteClick(it)) },
onItemClick = { onEvent(RecipeListEvent.RecipeClick(it)) },
)
}
isRefreshing -> {
CenteredProgressIndicator(
modifier = innerModifier
modifier = modifier
)
}
else -> {
RecipesListError(
modifier = innerModifier,
modifier = modifier,
recipes = recipes,
)
}
@@ -126,7 +166,7 @@ private fun RecipesListData(
recipes: LazyPagingItems<RecipeListItemState>,
onDeleteClick: (RecipeListItemState) -> Unit,
onFavoriteClick: (RecipeListItemState) -> Unit,
onItemClick: (RecipeListItemState) -> Unit
onItemClick: (RecipeListItemState) -> Unit,
) {
LazyPagingColumnPullRefresh(
modifier = modifier
@@ -155,3 +195,45 @@ private fun RecipesListData(
}
}
@Composable
internal fun RecipesTopAppBar(
searchQuery: String,
onValueChanged: (String) -> Unit,
drawerState: DrawerState,
) {
Row(
modifier = Modifier
.padding(
horizontal = Dimens.Medium,
vertical = Dimens.Small,
)
.clip(RoundedCornerShape(Dimens.Medium))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(end = Dimens.Medium),
verticalAlignment = Alignment.CenterVertically,
) {
OpenDrawerIconButton(
drawerState = drawerState,
)
SearchTextField(
modifier = Modifier
.weight(1f),
searchQuery = searchQuery,
onValueChanged = onValueChanged,
placeholder = R.string.search_recipes_hint,
)
}
}
@ColorSchemePreview
@Composable
private fun RecipesTopAppBarPreview() {
AppTheme {
RecipesTopAppBar(
searchQuery = "",
onValueChanged = {},
drawerState = rememberDrawerState(initialValue = DrawerValue.Closed),
)
}
}

View File

@@ -1,66 +0,0 @@
package gq.kirmanak.mealient.ui.recipes.list
import android.os.Bundle
import android.view.View
import androidx.compose.runtime.Composable
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.extensions.hideKeyboard
import gq.kirmanak.mealient.ui.BaseComposeFragment
import gq.kirmanak.mealient.ui.CheckableMenuItem
import gq.kirmanak.mealient.ui.activity.MainActivityViewModel
@AndroidEntryPoint
internal class RecipesListFragment : BaseComposeFragment() {
private val viewModel by viewModels<RecipesListViewModel>()
private val activityViewModel by activityViewModels<MainActivityViewModel>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
activityViewModel.updateUiState {
it.copy(
navigationVisible = true,
searchVisible = true,
checkedMenuItem = CheckableMenuItem.RecipesList,
)
}
}
@Composable
override fun Screen() = RecipesList(
recipesFlow = viewModel.pagingDataRecipeState,
onDeleteClick = { viewModel.onDeleteConfirm(it.entity) },
onFavoriteClick = { onFavoriteButtonClicked(it.entity) },
onItemClick = { onRecipeClicked(it.entity) },
onSnackbarShown = { viewModel.onSnackbarShown() },
snackbarMessageState = viewModel.snackbarState,
)
private fun onFavoriteButtonClicked(recipe: RecipeSummaryEntity) {
viewModel.onFavoriteIconClick(recipe)
}
private fun onRecipeClicked(recipe: RecipeSummaryEntity) {
viewModel.refreshRecipeInfo(recipe.slug).observe(viewLifecycleOwner) {
if (!isNavigatingSomewhere()) navigateToRecipeInfo(recipe.remoteId)
}
}
private fun isNavigatingSomewhere(): Boolean {
logger.v { "isNavigatingSomewhere() called" }
return findNavController().currentDestination?.id != R.id.recipesListFragment
}
private fun navigateToRecipeInfo(id: String) {
logger.v { "navigateToRecipeInfo() called with: id = $id" }
requireView().hideKeyboard()
findNavController().navigate(
RecipesListFragmentDirections.actionRecipesFragmentToRecipeInfoFragment(id)
)
}
}

View File

@@ -1,8 +1,6 @@
package gq.kirmanak.mealient.ui.recipes.list
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.liveData
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
@@ -14,11 +12,8 @@ import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProvider
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -26,6 +21,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -43,7 +39,7 @@ internal class RecipesListViewModel @Inject constructor(
private val showFavoriteIcon: StateFlow<Boolean> =
authRepo.isAuthorizedFlow.stateIn(viewModelScope, SharingStarted.Eagerly, false)
val pagingDataRecipeState: Flow<PagingData<RecipeListItemState>> =
private val pagingDataRecipeState: Flow<PagingData<RecipeListItemState>> =
pagingData.combine(showFavoriteIcon) { data, showFavorite ->
data.map { item ->
val imageUrl = recipeImageUrlProvider.generateImageUrl(item.imageId)
@@ -55,15 +51,10 @@ internal class RecipesListViewModel @Inject constructor(
}
}
private val _deleteRecipeResult = MutableSharedFlow<Result<Unit>>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
private val _screenState = MutableStateFlow(
RecipeListState(pagingDataRecipeState = pagingDataRecipeState)
)
val deleteRecipeResult: SharedFlow<Result<Unit>> get() = _deleteRecipeResult
private val _snackbarState = MutableStateFlow<RecipeListSnackbar?>(null)
val snackbarState get() = _snackbarState.asStateFlow()
val screenState: StateFlow<RecipeListState> get() = _screenState.asStateFlow()
init {
authRepo.isAuthorizedFlow.valueUpdatesOnly().onEach { hasAuthorized ->
@@ -72,23 +63,23 @@ internal class RecipesListViewModel @Inject constructor(
}.launchIn(viewModelScope)
}
fun refreshRecipeInfo(recipeSlug: String): LiveData<Result<Unit>> {
logger.v { "refreshRecipeInfo called with: recipeSlug = $recipeSlug" }
return liveData {
val result = recipeRepo.refreshRecipeInfo(recipeSlug)
logger.v { "refreshRecipeInfo: emitting $result" }
emit(result)
private fun onRecipeClicked(entity: RecipeSummaryEntity) {
logger.v { "onRecipeClicked() called with: entity = $entity" }
viewModelScope.launch {
val result = recipeRepo.refreshRecipeInfo(entity.slug)
logger.d { "Recipe info refreshed: $result" }
_screenState.update { it.copy(recipeIdToOpen = entity.remoteId) }
}
}
fun onFavoriteIconClick(recipeSummaryEntity: RecipeSummaryEntity) {
private fun onFavoriteIconClick(recipeSummaryEntity: RecipeSummaryEntity) {
logger.v { "onFavoriteIconClick() called with: recipeSummaryEntity = $recipeSummaryEntity" }
viewModelScope.launch {
val result = recipeRepo.updateIsRecipeFavorite(
recipeSlug = recipeSummaryEntity.slug,
isFavorite = recipeSummaryEntity.isFavorite.not(),
)
_snackbarState.value = result.fold(
val snackbar = result.fold(
onSuccess = { isFavorite ->
val name = recipeSummaryEntity.name
if (isFavorite) {
@@ -101,23 +92,69 @@ internal class RecipesListViewModel @Inject constructor(
RecipeListSnackbar.FavoriteUpdateFailed
}
)
_screenState.update { it.copy(snackbarState = snackbar) }
}
}
fun onDeleteConfirm(recipeSummaryEntity: RecipeSummaryEntity) {
private fun onDeleteConfirm(recipeSummaryEntity: RecipeSummaryEntity) {
logger.v { "onDeleteConfirm() called with: recipeSummaryEntity = $recipeSummaryEntity" }
viewModelScope.launch {
val result = recipeRepo.deleteRecipe(recipeSummaryEntity)
logger.d { "onDeleteConfirm: delete result is $result" }
_deleteRecipeResult.emit(result)
_snackbarState.value = result.fold(
val snackbar = result.fold(
onSuccess = { null },
onFailure = { RecipeListSnackbar.DeleteFailed },
)
_screenState.update { it.copy(snackbarState = snackbar) }
}
}
fun onSnackbarShown() {
_snackbarState.value = null
private fun onSnackbarShown() {
_screenState.update { it.copy(snackbarState = null) }
}
private fun onRecipeOpen() {
logger.v { "onRecipeOpen() called" }
_screenState.update { it.copy(recipeIdToOpen = null) }
}
fun onEvent(event: RecipeListEvent) {
logger.v { "onEvent() called with: event = $event" }
when (event) {
is RecipeListEvent.DeleteConfirmed -> onDeleteConfirm(event.recipe.entity)
is RecipeListEvent.FavoriteClick -> onFavoriteIconClick(event.recipe.entity)
is RecipeListEvent.RecipeClick -> onRecipeClicked(event.recipe.entity)
is RecipeListEvent.SnackbarShown -> onSnackbarShown()
is RecipeListEvent.RecipeOpened -> onRecipeOpen()
is RecipeListEvent.SearchQueryChanged -> onSearchQueryChanged(event)
}
}
private fun onSearchQueryChanged(event: RecipeListEvent.SearchQueryChanged) {
logger.v { "onSearchQueryChanged() called with: event = $event" }
_screenState.update { it.copy(searchQuery = event.query) }
recipeRepo.updateNameQuery(event.query)
}
}
internal data class RecipeListState(
val pagingDataRecipeState: Flow<PagingData<RecipeListItemState>>,
val snackbarState: RecipeListSnackbar? = null,
val recipeIdToOpen: String? = null,
val searchQuery: String = "",
)
internal sealed interface RecipeListEvent {
data class DeleteConfirmed(val recipe: RecipeListItemState) : RecipeListEvent
data class FavoriteClick(val recipe: RecipeListItemState) : RecipeListEvent
data class RecipeClick(val recipe: RecipeListItemState) : RecipeListEvent
data object RecipeOpened : RecipeListEvent
data object SnackbarShown : RecipeListEvent
data class SearchQueryChanged(val query: String) : RecipeListEvent
}

View File

@@ -0,0 +1,71 @@
package gq.kirmanak.mealient.ui.recipes.list
import androidx.annotation.StringRes
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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 gq.kirmanak.mealient.R
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.preview.ColorSchemePreview
@Composable
internal fun SearchTextField(
searchQuery: String,
onValueChanged: (String) -> Unit,
@StringRes placeholder: Int,
modifier: Modifier = Modifier,
) {
TextField(
modifier = modifier
.semantics { testTag = "search-recipes-field" },
value = searchQuery,
onValueChange = onValueChanged,
placeholder = {
Text(
text = stringResource(id = placeholder),
)
},
leadingIcon = {
Icon(
imageVector = Icons.Default.Search,
contentDescription = null,
)
},
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Search,
),
keyboardActions = KeyboardActions(
onSearch = { defaultKeyboardAction(ImeAction.Done) }
),
singleLine = true,
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Unspecified,
unfocusedIndicatorColor = Color.Unspecified,
disabledIndicatorColor = Color.Unspecified,
)
)
}
@ColorSchemePreview
@Composable
private fun SearchTextFieldPreview() {
AppTheme {
SearchTextField(
searchQuery = "",
onValueChanged = {},
placeholder = R.string.search_recipes_hint,
)
}
}

View File

@@ -1,31 +1,35 @@
package gq.kirmanak.mealient.ui.share
import android.content.Intent
import android.graphics.drawable.Animatable2
import android.graphics.drawable.AnimatedVectorDrawable
import android.graphics.drawable.Drawable
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.core.view.isInvisible
import androidx.core.view.postDelayed
import androidx.core.view.WindowInsetsControllerCompat
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.databinding.ActivityShareRecipeBinding
import gq.kirmanak.mealient.extensions.isDarkThemeOn
import gq.kirmanak.mealient.extensions.showLongToast
import gq.kirmanak.mealient.ui.BaseActivity
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.OperationUiState
import javax.inject.Inject
@AndroidEntryPoint
class ShareRecipeActivity : BaseActivity<ActivityShareRecipeBinding>(
binder = ActivityShareRecipeBinding::bind,
containerId = R.id.root,
layoutRes = R.layout.activity_share_recipe,
) {
class ShareRecipeActivity : ComponentActivity() {
@Inject
lateinit var logger: Logger
private val viewModel: ShareRecipeViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
with(WindowInsetsControllerCompat(window, window.decorView)) {
val isAppearanceLightBars = !isDarkThemeOn()
isAppearanceLightNavigationBars = isAppearanceLightBars
isAppearanceLightStatusBars = isAppearanceLightBars
}
if (intent.action != Intent.ACTION_SEND || intent.type != "text/plain") {
logger.w { "onCreate: intent.action = ${intent.action}, intent.type = ${intent.type}" }
@@ -40,16 +44,17 @@ class ShareRecipeActivity : BaseActivity<ActivityShareRecipeBinding>(
return
}
restartAnimationOnEnd()
viewModel.saveResult.observe(this, ::onStateUpdate)
viewModel.saveRecipeByURL(url)
setContent {
AppTheme {
ShareRecipeScreen()
}
}
}
private fun onStateUpdate(state: OperationUiState<String>) {
binding.progress.isInvisible = !state.isProgress
withAnimatedDrawable {
if (state.isProgress) start() else stop()
}
if (state.isSuccess || state.isFailure) {
showLongToast(
if (state.isSuccess) R.string.activity_share_recipe_success_toast
@@ -59,35 +64,5 @@ class ShareRecipeActivity : BaseActivity<ActivityShareRecipeBinding>(
}
}
private fun restartAnimationOnEnd() {
withAnimatedDrawable {
onAnimationEnd {
if (viewModel.saveResult.value?.isProgress == true) {
binding.progress.postDelayed(250) { start() }
}
}
}
}
private inline fun withAnimatedDrawable(block: AnimatedVectorDrawable.() -> Unit) {
binding.progress.drawable.let { drawable ->
if (drawable is AnimatedVectorDrawable) {
drawable.block()
} else {
logger.w { "withAnimatedDrawable: progress's drawable is not AnimatedVectorDrawable" }
}
}
}
}
private inline fun AnimatedVectorDrawable.onAnimationEnd(
crossinline block: AnimatedVectorDrawable.() -> Unit,
): Animatable2.AnimationCallback {
val callback = object : Animatable2.AnimationCallback() {
override fun onAnimationEnd(drawable: Drawable?) {
block()
}
}
registerAnimationCallback(callback)
return callback
}

View File

@@ -0,0 +1,76 @@
package gq.kirmanak.mealient.ui.share
import androidx.annotation.DrawableRes
import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi
import androidx.compose.animation.graphics.res.animatedVectorResource
import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
import androidx.compose.animation.graphics.vector.AnimatedImageVector
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.stringResource
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.components.BaseScreen
import gq.kirmanak.mealient.ui.preview.ColorSchemePreview
import kotlinx.coroutines.delay
@Composable
internal fun ShareRecipeScreen() {
BaseScreen { modifier ->
Box(
modifier = modifier
.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Image(
painter = loopAnimationPainter(
resId = R.drawable.ic_progress_bar,
delayBeforeRestart = 1000, // Animation takes 800 ms
),
contentDescription = stringResource(
id = R.string.content_description_activity_share_recipe_progress,
),
)
}
}
}
@Composable
@OptIn(ExperimentalAnimationGraphicsApi::class)
@Suppress("SameParameterValue")
private fun loopAnimationPainter(
@DrawableRes resId: Int,
delayBeforeRestart: Long,
): Painter {
val image = AnimatedImageVector.animatedVectorResource(id = resId)
var atEnd by remember { mutableStateOf(false) }
LaunchedEffect(image) {
while (true) {
delay(delayBeforeRestart)
atEnd = !atEnd
}
}
return rememberAnimatedVectorPainter(
animatedImageVector = image,
atEnd = atEnd,
)
}
@ColorSchemePreview
@Composable
private fun ShareRecipeScreenPreview() {
AppTheme {
ShareRecipeScreen()
}
}

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="20dp" />
<solid android:color="?colorSurfaceVariant" />
</shape>

View File

@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M11,19V13H5V11H11V5H13V11H19V13H13V19Z" />
</vector>

View File

@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M7,21 L2,16 7,11 8.425,12.4 5.825,15H21V17H5.825L8.425,19.6ZM17,13 L15.575,11.6 18.175,9H3V7H18.175L15.575,4.4L17,3L22,8Z" />
</vector>

View File

@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:tint="?attr/colorPrimary"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M7,21Q6.175,21 5.588,20.413Q5,19.825 5,19V6H4V4H9V3H15V4H20V6H19V19Q19,19.825 18.413,20.413Q17.825,21 17,21ZM17,6H7V19Q7,19 7,19Q7,19 7,19H17Q17,19 17,19Q17,19 17,19ZM9,17H11V8H9ZM13,17H15V8H13ZM7,6V19Q7,19 7,19Q7,19 7,19Q7,19 7,19Q7,19 7,19Z" />
</vector>

View File

@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:tint="?attr/colorPrimary"
android:viewportWidth="40"
android:viewportHeight="40">
<path
android:fillColor="@android:color/white"
android:pathData="M20,34.958 L18.042,33.208Q13.708,29.25 10.875,26.375Q8.042,23.5 6.354,21.229Q4.667,18.958 4,17.104Q3.333,15.25 3.333,13.333Q3.333,9.542 5.896,6.979Q8.458,4.417 12.208,4.417Q14.542,4.417 16.542,5.479Q18.542,6.542 20,8.5Q21.625,6.458 23.583,5.438Q25.542,4.417 27.792,4.417Q31.542,4.417 34.104,6.979Q36.667,9.542 36.667,13.333Q36.667,15.25 36,17.104Q35.333,18.958 33.646,21.229Q31.958,23.5 29.125,26.375Q26.292,29.25 21.958,33.208Z" />
</vector>

View File

@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:tint="?attr/colorPrimary"
android:viewportWidth="40"
android:viewportHeight="40">
<path
android:fillColor="@android:color/white"
android:pathData="M20,34.958 L18.042,33.208Q13.708,29.25 10.875,26.375Q8.042,23.5 6.354,21.229Q4.667,18.958 4,17.104Q3.333,15.25 3.333,13.333Q3.333,9.542 5.896,6.979Q8.458,4.417 12.208,4.417Q14.542,4.417 16.542,5.479Q18.542,6.542 20,8.5Q21.625,6.458 23.583,5.438Q25.542,4.417 27.792,4.417Q31.542,4.417 34.104,6.979Q36.667,9.542 36.667,13.333Q36.667,15.25 36,17.104Q35.333,18.958 33.646,21.229Q31.958,23.5 29.125,26.375Q26.292,29.25 21.958,33.208ZM20,31.292Q24.125,27.5 26.812,24.792Q29.5,22.083 31.062,20.062Q32.625,18.042 33.25,16.458Q33.875,14.875 33.875,13.333Q33.875,10.667 32.167,8.938Q30.458,7.208 27.792,7.208Q25.708,7.208 23.938,8.438Q22.167,9.667 21.208,11.833H18.792Q17.833,9.708 16.062,8.458Q14.292,7.208 12.208,7.208Q9.542,7.208 7.833,8.938Q6.125,10.667 6.125,13.333Q6.125,14.917 6.75,16.5Q7.375,18.083 8.938,20.125Q10.5,22.167 13.188,24.854Q15.875,27.542 20,31.292ZM20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Z" />
</vector>

View File

@@ -1,11 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M4,17Q3.575,17 3.288,16.712Q3,16.425 3,16Q3,15.575 3.288,15.287Q3.575,15 4,15Q4.425,15 4.713,15.287Q5,15.575 5,16Q5,16.425 4.713,16.712Q4.425,17 4,17ZM4,13Q3.575,13 3.288,12.712Q3,12.425 3,12Q3,11.575 3.288,11.287Q3.575,11 4,11Q4.425,11 4.713,11.287Q5,11.575 5,12Q5,12.425 4.713,12.712Q4.425,13 4,13ZM4,9Q3.575,9 3.288,8.712Q3,8.425 3,8Q3,7.575 3.288,7.287Q3.575,7 4,7Q4.425,7 4.713,7.287Q5,7.575 5,8Q5,8.425 4.713,8.712Q4.425,9 4,9ZM7,17V15H21V17ZM7,13V11H21V13ZM7,9V7H21V9Z" />
</vector>

View File

@@ -1,11 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,21V19H19Q19,19 19,19Q19,19 19,19V5Q19,5 19,5Q19,5 19,5H12V3H19Q19.825,3 20.413,3.587Q21,4.175 21,5V19Q21,19.825 20.413,20.413Q19.825,21 19,21ZM10,17 L8.625,15.55 11.175,13H3V11H11.175L8.625,8.45L10,7L15,12Z" />
</vector>

View File

@@ -1,11 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M5,21Q4.175,21 3.587,20.413Q3,19.825 3,19V5Q3,4.175 3.587,3.587Q4.175,3 5,3H12V5H5Q5,5 5,5Q5,5 5,5V19Q5,19 5,19Q5,19 5,19H12V21ZM16,17 L14.625,15.55 17.175,13H9V11H17.175L14.625,8.45L16,7L21,12Z" />
</vector>

View File

@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M3,18V16H21V18ZM3,13V11H21V13ZM3,8V6H21V8Z" />
</vector>

View File

@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M784,840L532,588Q502,612 463,626Q424,640 380,640Q271,640 195.5,564.5Q120,489 120,380Q120,271 195.5,195.5Q271,120 380,120Q489,120 564.5,195.5Q640,271 640,380Q640,424 626,463Q612,502 588,532L840,784L784,840ZM380,560Q455,560 507.5,507.5Q560,455 560,380Q560,305 507.5,252.5Q455,200 380,200Q305,200 252.5,252.5Q200,305 200,380Q200,455 252.5,507.5Q305,560 380,560Z" />
</vector>

View File

@@ -1,11 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M120,800L120,160L880,480L120,800ZM200,680L674,480L200,280L200,420L440,480L200,540L200,680ZM200,680L200,480L200,280L200,420L200,420L200,540L200,540L200,680Z" />
</vector>

View File

@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/content_description_activity_share_recipe_progress"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_progress_bar" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -1,142 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.add.AddRecipeFragment">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/holder"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="200dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/recipe_name_input_layout"
style="@style/SmallMarginTextInputLayoutStyle"
android:hint="@string/fragment_add_recipe_recipe_name"
app:layout_constraintBottom_toTopOf="@+id/recipe_description_input_layout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/recipe_name_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/recipe_description_input_layout"
style="@style/SmallMarginTextInputLayoutStyle"
android:hint="@string/fragment_add_recipe_recipe_description"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/recipe_name_input_layout">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/recipe_description_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textMultiLine" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/recipe_yield_input_layout"
style="@style/SmallMarginTextInputLayoutStyle"
android:hint="@string/fragment_add_recipe_recipe_yield"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/recipe_description_input_layout">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/recipe_yield_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<androidx.constraintlayout.helper.widget.Flow
android:id="@+id/ingredients_flow"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="@dimen/margin_small"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/recipe_yield_input_layout" />
<Button
android:id="@+id/new_ingredient_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="@dimen/margin_small"
android:text="@string/fragment_add_recipe_new_ingredient"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/ingredients_flow" />
<androidx.constraintlayout.helper.widget.Flow
android:id="@+id/instructions_flow"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="@dimen/margin_small"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/new_ingredient_button" />
<Button
android:id="@+id/new_instruction_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="@dimen/margin_small"
android:text="@string/fragment_add_recipe_new_instruction"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/instructions_flow" />
<androidx.constraintlayout.helper.widget.Flow
android:id="@+id/switches_flow"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="@dimen/margin_small"
android:orientation="vertical"
app:constraint_referenced_ids="public_recipe,disable_comments"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/new_instruction_button" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/public_recipe"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/fragment_add_recipe_public_recipe" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/disable_comments"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/fragment_add_recipe_disable_comments" />
<Button
android:id="@+id/save_recipe_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="@dimen/margin_small"
android:text="@string/fragment_add_recipe_save_button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/clear_button"
app:layout_constraintTop_toBottomOf="@+id/switches_flow" />
<Button
android:id="@+id/clear_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="@dimen/margin_small"
android:text="@string/fragment_add_recipe_clear_button"
app:layout_constraintEnd_toStartOf="@+id/save_recipe_button"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/switches_flow" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>

View File

@@ -1,64 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.auth.AuthenticationFragment">
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress"
style="@style/IndeterminateProgress"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/email_input_layout"
style="@style/SmallMarginTextInputLayoutStyle"
android:hint="@string/fragment_authentication_input_hint_email"
app:layout_constraintBottom_toTopOf="@+id/password_input_layout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.1"
app:helperText="@string/fragment_authentication_email_input_helper_text"
app:helperTextEnabled="true"
app:layout_constraintVertical_chainStyle="packed">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/email_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textEmailAddress" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/password_input_layout"
style="@style/SmallMarginTextInputLayoutStyle"
android:hint="@string/fragment_authentication_input_hint_password"
app:layout_constraintBottom_toTopOf="@+id/button"
app:layout_constraintEnd_toEndOf="parent"
app:endIconMode="password_toggle"
app:layout_constraintStart_toStartOf="parent"
app:helperText="@string/fragment_authentication_password_input_helper_text"
app:helperTextEnabled="true"
app:layout_constraintTop_toBottomOf="@+id/email_input_layout">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/password_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/button"
style="@style/SmallMarginButton"
android:text="@string/fragment_authentication_button_login"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/password_input_layout" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -1,45 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.baseurl.BaseURLFragment">
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress"
style="@style/IndeterminateProgress"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/url_input_layout"
style="@style/SmallMarginTextInputLayoutStyle"
android:hint="@string/fragment_authentication_input_hint_url"
app:helperText="@string/fragment_base_url_url_input_helper_text"
app:helperTextEnabled="true"
app:layout_constraintBottom_toTopOf="@+id/button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.2"
app:layout_constraintVertical_chainStyle="packed">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/url_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textUri" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/button"
style="@style/SmallMarginButton"
android:text="@string/fragment_base_url_save"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/url_input_layout" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -1,41 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
style="?materialCardViewElevatedStyle"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="ui.disclaimer.DisclaimerFragment">
<com.google.android.material.card.MaterialCardView
android:id="@+id/main_text_holder"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_medium"
android:layout_marginTop="40dp"
app:layout_constraintBottom_toTopOf="@+id/okay"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/main_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_medium"
android:layout_marginVertical="25dp"
android:text="@string/fragment_disclaimer_main_text"
android:textAlignment="center"
android:textAppearance="?textAppearanceHeadline6" />
</com.google.android.material.card.MaterialCardView>
<Button
android:id="@+id/okay"
style="@style/SmallMarginButton"
android:clickable="false"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/main_text_holder"
tools:text="Okay (3 seconds)" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -1,45 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/drawer"
style="?drawerLayoutStyle"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.activity.MainActivity">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<gq.kirmanak.mealient.ui.activity.ToolbarView
android:id="@+id/toolbar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_medium"
android:background="@drawable/bg_toolbar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="@dimen/margin_small"
app:defaultNavHost="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar" />
</androidx.constraintlayout.widget.ConstraintLayout>
<com.google.android.material.navigation.NavigationView
android:id="@+id/navigation_view"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
app:headerLayout="@layout/view_navigation_drawer_header"
app:menu="@menu/navigation_menu" />
</androidx.drawerlayout.widget.DrawerLayout>

View File

@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_medium"
android:layout_marginTop="@dimen/margin_small"
android:text="@string/menu_navigation_drawer_header"
android:textAppearance="?attr/textAppearanceHeadlineSmall"
android:textColor="?attr/colorPrimary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.textfield.TextInputLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:endIconMode="clear_text">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textMultiLine" />
</com.google.android.material.textfield.TextInputLayout>

View File

@@ -1,40 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context=".ui.activity.ToolbarView"
tools:layout_height="wrap_content"
tools:layout_width="match_parent"
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
<ImageView
android:id="@+id/navigation_icon"
android:layout_width="0dp"
android:layout_height="32dp"
android:layout_marginVertical="@dimen/margin_small"
android:layout_marginStart="@dimen/margin_small"
android:contentDescription="@string/view_toolbar_navigation_icon_content_description"
android:src="@drawable/ic_menu"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<EditText
android:id="@+id/search_edit"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_margin="@dimen/margin_small"
android:background="@null"
android:drawableStart="@drawable/ic_search"
android:hint="@string/search_recipes_hint"
android:importantForAutofill="no"
android:inputType="textFilter"
android:textAppearance="?textAppearanceTitleLarge"
android:textColor="?colorOnSurfaceVariant"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/navigation_icon"
app:layout_constraintTop_toTopOf="parent"
tools:text="Search request" />
</merge>

View File

@@ -1,43 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/recipes_list"
android:checkable="true"
android:icon="@drawable/ic_list"
android:title="@string/menu_navigation_drawer_recipes_list" />
<item
android:id="@+id/add_recipe"
android:checkable="true"
android:icon="@drawable/ic_add"
android:title="@string/menu_navigation_drawer_add_recipe" />
<item
android:id="@+id/shopping_lists"
android:checkable="true"
android:icon="@drawable/ic_shopping_cart"
android:title="@string/menu_navigation_drawer_shopping_lists" />
<item
android:id="@+id/change_url"
android:checkable="true"
android:icon="@drawable/ic_change"
android:title="@string/menu_navigation_drawer_change_url" />
<item
android:id="@+id/login"
android:checkable="true"
android:icon="@drawable/ic_login"
android:title="@string/menu_navigation_drawer_login" />
<item
android:id="@+id/logout"
android:icon="@drawable/ic_logout"
android:title="@string/menu_navigation_drawer_logout" />
<item
android:id="@+id/email_logs"
android:icon="@drawable/ic_send"
android:title="@string/menu_navigation_drawer_email_logs" />
</menu>

View File

@@ -1,95 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/nav_graph"
tools:ignore="InvalidNavigation">
<fragment
android:id="@+id/authenticationFragment"
android:name="gq.kirmanak.mealient.ui.auth.AuthenticationFragment"
android:label="AuthenticationFragment"
tools:layout="@layout/fragment_authentication" />
<fragment
android:id="@+id/recipesListFragment"
android:name="gq.kirmanak.mealient.ui.recipes.list.RecipesListFragment">
<action
android:id="@+id/action_recipesFragment_to_recipeInfoFragment"
app:destination="@id/recipeInfoFragment"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
</fragment>
<fragment
android:id="@+id/recipeInfoFragment"
android:name="gq.kirmanak.mealient.ui.recipes.info.RecipeInfoFragment"
android:label="RecipeInfoFragment">
<argument
android:name="recipe_id"
app:argType="string" />
</fragment>
<fragment
android:id="@+id/disclaimerFragment"
android:name="gq.kirmanak.mealient.ui.disclaimer.DisclaimerFragment"
android:label="DisclaimerFragment"
tools:layout="@layout/fragment_disclaimer">
<action
android:id="@+id/action_disclaimerFragment_to_baseURLFragment"
app:destination="@id/baseURLFragment"
app:popUpTo="@id/nav_graph"
app:popUpToInclusive="true" />
</fragment>
<fragment
android:id="@+id/baseURLFragment"
android:name="gq.kirmanak.mealient.ui.baseurl.BaseURLFragment"
android:label="fragment_base_url"
tools:layout="@layout/fragment_base_url">
<action
android:id="@+id/action_baseURLFragment_to_recipesListFragment"
app:destination="@id/recipesListFragment"
app:popUpTo="@id/nav_graph" />
<argument
android:name="isOnboarding"
app:argType="boolean" />
</fragment>
<fragment
android:id="@+id/addRecipeFragment"
android:name="gq.kirmanak.mealient.ui.add.AddRecipeFragment"
android:label="fragment_add_recipe"
tools:layout="@layout/fragment_add_recipe" />
<fragment
android:id="@+id/shoppingListsFragment"
android:name="gq.kirmanak.mealient.shopping_lists.ui.list.ShoppingListsFragment" />
<action
android:id="@+id/action_global_authenticationFragment"
app:destination="@id/authenticationFragment" />
<action
android:id="@+id/action_global_recipesListFragment"
app:destination="@id/recipesListFragment"
app:popUpTo="@id/nav_graph" />
<action
android:id="@+id/action_global_addRecipeFragment"
app:destination="@id/addRecipeFragment"
app:popUpTo="@id/recipesListFragment" />
<action
android:id="@+id/action_global_baseURLFragment"
app:destination="@id/baseURLFragment"
app:popUpTo="@id/recipesListFragment" />
<action
android:id="@+id/action_global_shoppingListsFragment"
app:destination="@id/shoppingListsFragment"
app:popUpTo="@id/recipesListFragment" />
</navigation>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="margin_small">8dp</dimen>
<dimen name="margin_medium">16dp</dimen>
</resources>

View File

@@ -1,5 +1,4 @@
<resources>
<string name="app_name" translatable="false">Mealient</string>
<string name="fragment_authentication_input_hint_email">Email or username</string>
<string name="fragment_authentication_input_hint_password">Password</string>
<string name="fragment_authentication_input_hint_url">Server URL</string>
@@ -61,7 +60,6 @@
<string name="fragment_recipes_delete_recipe_confirm_dialog_negative_btn">Cancel</string>
<string name="menu_navigation_drawer_change_url">Change URL</string>
<string name="search_recipes_hint">Search recipes</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>
<string name="fragment_recipes_list_no_recipes">No recipes</string>
<string name="activity_share_recipe_success_toast">Recipe saved successfully.</string>
@@ -79,4 +77,8 @@
<string name="activity_main_email_logs_confirmation_title">Sending sensitive data</string>
<string name="activity_main_email_logs_confirmation_positive">Choose how to send</string>
<string name="activity_main_email_logs_confirmation_negative">Cancel</string>
<string name="activity_main_logout_confirmation_title">Logging out</string>
<string name="activity_main_logout_confirmation_message">Are you sure you want to log yourself out?</string>
<string name="activity_main_logout_confirmation_positive">Log out</string>
<string name="activity_main_logout_confirmation_negative">Cancel</string>
</resources>

View File

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="SmallMarginTextInputLayoutStyle">
<item name="android:layout_width">0dp</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:layout_margin">@dimen/margin_small</item>
</style>
<style name="SmallMarginButton">
<item name="android:layout_width">wrap_content</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:layout_margin">@dimen/margin_small</item>
</style>
<style name="IndeterminateProgress">
<item name="android:layout_width">0dp</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:indeterminate">true</item>
<item name="android:visibility">gone</item>
</style>
</resources>

View File

@@ -1,70 +0,0 @@
package gq.kirmanak.mealient.ui.activity
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage
import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL
import gq.kirmanak.mealient.test.BaseUnitTest
import gq.kirmanak.mealient.ui.ActivityUiState
import gq.kirmanak.mealient.ui.ActivityUiStateController
import io.mockk.coEvery
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import org.junit.Before
import org.junit.Test
class MainActivityViewModelTest : BaseUnitTest() {
@MockK(relaxUnitFun = true)
lateinit var authRepo: AuthRepo
@MockK(relaxUnitFun = true)
lateinit var disclaimerStorage: DisclaimerStorage
@MockK(relaxUnitFun = true)
lateinit var serverInfoRepo: ServerInfoRepo
@MockK(relaxUnitFun = true)
lateinit var recipeRepo: RecipeRepo
@MockK(relaxUnitFun = true)
lateinit var activityUiStateController: ActivityUiStateController
private lateinit var subject: MainActivityViewModel
@Before
override fun setUp() {
super.setUp()
every { authRepo.isAuthorizedFlow } returns emptyFlow()
coEvery { disclaimerStorage.isDisclaimerAccepted() } returns true
coEvery { serverInfoRepo.getUrl() } returns TEST_BASE_URL
every { activityUiStateController.getUiStateFlow() } returns MutableStateFlow(
ActivityUiState()
)
subject = MainActivityViewModel(
authRepo = authRepo,
logger = logger,
disclaimerStorage = disclaimerStorage,
serverInfoRepo = serverInfoRepo,
recipeRepo = recipeRepo,
activityUiStateController = activityUiStateController,
)
}
@Test
fun `when onSearchQuery with query expect call to recipe repo`() {
subject.onSearchQuery("query")
verify { recipeRepo.updateNameQuery("query") }
}
@Test
fun `when onSearchQuery with null expect call to recipe repo`() {
subject.onSearchQuery("query")
subject.onSearchQuery(null)
verify { recipeRepo.updateNameQuery(null) }
}
}

View File

@@ -2,20 +2,20 @@ package gq.kirmanak.mealient.ui.add
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.add.AddRecipeRepo
import gq.kirmanak.mealient.datasource.models.AddRecipeInfo
import gq.kirmanak.mealient.datasource.models.AddRecipeSettingsInfo
import gq.kirmanak.mealient.datasource_test.PORRIDGE_ADD_RECIPE_INFO
import gq.kirmanak.mealient.test.BaseUnitTest
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import io.mockk.slot
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeoutOrNull
import org.junit.Before
import org.junit.Test
class AddRecipeViewModelTest : BaseUnitTest() {
internal class AddRecipeViewModelTest : BaseUnitTest() {
@MockK(relaxUnitFun = true)
lateinit var addRecipeRepo: AddRecipeRepo
@@ -25,50 +25,79 @@ class AddRecipeViewModelTest : BaseUnitTest() {
@Before
override fun setUp() {
super.setUp()
coEvery { addRecipeRepo.addRecipeRequestFlow } returns flowOf(EMPTY_ADD_RECIPE_INFO)
subject = AddRecipeViewModel(addRecipeRepo, logger)
}
@Test
fun `when saveRecipe fails then addRecipeResult is false`() = runTest {
coEvery { addRecipeRepo.saveRecipe() } throws IllegalStateException()
subject.saveRecipe()
assertThat(subject.addRecipeResult.first()).isFalse()
subject.onEvent(AddRecipeScreenEvent.SaveRecipeClick)
assertThat(subject.screenState.value.snackbarMessage)
.isEqualTo(AddRecipeSnackbarMessage.Error)
}
@Test
fun `when saveRecipe succeeds then addRecipeResult is true`() = runTest {
coEvery { addRecipeRepo.saveRecipe() } returns "recipe-slug"
subject.saveRecipe()
assertThat(subject.addRecipeResult.first()).isTrue()
subject.onEvent(AddRecipeScreenEvent.SaveRecipeClick)
assertThat(subject.screenState.value.snackbarMessage)
.isEqualTo(AddRecipeSnackbarMessage.Success)
}
@Test
fun `when preserve then doesn't update UI`() {
coEvery { addRecipeRepo.addRecipeRequestFlow } returns flowOf(PORRIDGE_ADD_RECIPE_INFO)
subject.preserve(PORRIDGE_ADD_RECIPE_INFO)
coVerify(inverse = true) { addRecipeRepo.addRecipeRequestFlow }
fun `when UI is updated then preserves`() {
subject.onEvent(AddRecipeScreenEvent.RecipeNameInput("Porridge"))
val infoSlot = slot<AddRecipeInfo>()
coVerify { addRecipeRepo.preserve(capture(infoSlot)) }
assertThat(infoSlot.captured.name).isEqualTo("Porridge")
}
@Test
fun `when preservedAddRecipeRequest without loadPreservedRequest then empty`() = runTest {
coEvery { addRecipeRepo.addRecipeRequestFlow } returns flowOf(PORRIDGE_ADD_RECIPE_INFO)
val actual = withTimeoutOrNull(10) { subject.preservedAddRecipeRequest.firstOrNull() }
assertThat(actual).isNull()
}
@Test
fun `when loadPreservedRequest then updates preservedAddRecipeRequest`() = runTest {
fun `when loadPreservedRequest then updates screenState`() = runTest {
val expected = PORRIDGE_ADD_RECIPE_INFO
coEvery { addRecipeRepo.addRecipeRequestFlow } returns flowOf(expected)
subject.loadPreservedRequest()
assertThat(subject.preservedAddRecipeRequest.first()).isSameInstanceAs(expected)
subject.doLoadPreservedRequest()
val screenState = subject.screenState.value
assertThat(screenState.recipeNameInput).isSameInstanceAs("Porridge")
assertThat(screenState.recipeDescriptionInput).isSameInstanceAs("A tasty porridge")
assertThat(screenState.recipeYieldInput).isSameInstanceAs("3 servings")
assertThat(screenState.isPublicRecipe).isSameInstanceAs(true)
assertThat(screenState.disableComments).isSameInstanceAs(false)
assertThat(screenState.ingredients).isEqualTo(
listOf("2 oz of white milk", "2 oz of white sugar")
)
assertThat(screenState.instructions).isEqualTo(
listOf("Mix the ingredients", "Boil the ingredients")
)
}
@Test
fun `when clear then updates preservedAddRecipeRequest`() = runTest {
val expected = PORRIDGE_ADD_RECIPE_INFO
coEvery { addRecipeRepo.addRecipeRequestFlow } returns flowOf(expected)
subject.clear()
assertThat(subject.preservedAddRecipeRequest.first()).isSameInstanceAs(expected)
fun `when initialized then name is empty`() {
assertThat(subject.screenState.value.recipeNameInput).isEmpty()
}
@Test
fun `when recipe name entered then screen state is updated`() {
subject.onEvent(AddRecipeScreenEvent.RecipeNameInput("Porridge"))
assertThat(subject.screenState.value.recipeNameInput).isEqualTo("Porridge")
}
@Test
fun `when clear then updates screen state`() = runTest {
subject.onEvent(AddRecipeScreenEvent.RecipeNameInput("Porridge"))
subject.onEvent(AddRecipeScreenEvent.ClearInputClick)
assertThat(subject.screenState.value.recipeNameInput).isEmpty()
}
companion object {
private val EMPTY_ADD_RECIPE_INFO = AddRecipeInfo(
name = "",
description = "",
recipeYield = "",
recipeInstructions = emptyList(),
recipeIngredient = emptyList(),
settings = AddRecipeSettingsInfo(public = false, disableComments = false)
)
}
}

View File

@@ -1,5 +1,6 @@
package gq.kirmanak.mealient.ui.baseurl
import android.app.Application
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
@@ -9,10 +10,10 @@ import gq.kirmanak.mealient.datasource.NetworkError
import gq.kirmanak.mealient.datasource.TrustedCertificatesStore
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL
import gq.kirmanak.mealient.test.BaseUnitTest
import gq.kirmanak.mealient.ui.OperationUiState
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.impl.annotations.RelaxedMockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -25,7 +26,7 @@ import java.io.IOException
import javax.net.ssl.SSLHandshakeException
@OptIn(ExperimentalCoroutinesApi::class)
class BaseURLViewModelTest : BaseUnitTest() {
internal class BaseURLViewModelTest : BaseUnitTest() {
@MockK(relaxUnitFun = true)
lateinit var serverInfoRepo: ServerInfoRepo
@@ -42,12 +43,19 @@ class BaseURLViewModelTest : BaseUnitTest() {
@RelaxedMockK
lateinit var baseUrlLogRedactor: BaseUrlLogRedactor
@MockK(relaxUnitFun = true)
lateinit var application: Application
lateinit var subject: BaseURLViewModel
@Before
override fun setUp() {
super.setUp()
every { application.getString(any()) } returns ""
every { application.getString(any(), any()) } returns ""
coEvery { serverInfoRepo.getUrl() } returns null
subject = BaseURLViewModel(
application = application,
serverInfoRepo = serverInfoRepo,
authRepo = authRepo,
recipeRepo = recipeRepo,
@@ -119,7 +127,7 @@ class BaseURLViewModelTest : BaseUnitTest() {
coEvery { serverInfoRepo.tryBaseURL(any()) } returns Result.failure(IOException())
subject.saveBaseUrl(TEST_BASE_URL)
advanceUntilIdle()
assertThat(subject.uiState.value).isInstanceOf(OperationUiState.Failure::class.java)
assertThat(subject.screenState.value.errorText).isNotNull()
}
@Test

View File

@@ -13,7 +13,7 @@ import org.junit.Test
import java.util.concurrent.TimeUnit
@OptIn(ExperimentalCoroutinesApi::class)
class DisclaimerViewModelTest : BaseUnitTest() {
internal class DisclaimerViewModelTest : BaseUnitTest() {
@MockK(relaxUnitFun = true)
lateinit var storage: DisclaimerStorage

View File

@@ -1,32 +1,27 @@
package gq.kirmanak.mealient.ui.recipes
import androidx.lifecycle.asFlow
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProvider
import gq.kirmanak.mealient.database.CAKE_RECIPE_SUMMARY_ENTITY
import gq.kirmanak.mealient.test.BaseUnitTest
import gq.kirmanak.mealient.ui.recipes.list.RecipeListEvent
import gq.kirmanak.mealient.ui.recipes.list.RecipeListItemState
import gq.kirmanak.mealient.ui.recipes.list.RecipeListSnackbar
import gq.kirmanak.mealient.ui.recipes.list.RecipesListViewModel
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import io.mockk.verify
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import java.io.IOException
@OptIn(ExperimentalCoroutinesApi::class)
class RecipesListViewModelTest : BaseUnitTest() {
internal class RecipesListViewModelTest : BaseUnitTest() {
@MockK
lateinit var authRepo: AuthRepo
@@ -64,61 +59,63 @@ class RecipesListViewModelTest : BaseUnitTest() {
}
@Test
fun `when refreshRecipeInfo succeeds expect successful result`() = runTest {
val slug = "cake"
coEvery { recipeRepo.refreshRecipeInfo(eq(slug)) } returns Result.success(Unit)
val actual = createSubject().refreshRecipeInfo(slug).asFlow().first()
assertThat(actual).isEqualTo(Result.success(Unit))
fun `when SearchQueryChanged happens with query expect call to recipe repo`() {
val subject = createSubject()
subject.onEvent(RecipeListEvent.SearchQueryChanged("query"))
verify { recipeRepo.updateNameQuery("query") }
}
@Test
fun `when refreshRecipeInfo succeeds expect call to repo`() = runTest {
val slug = "cake"
coEvery { recipeRepo.refreshRecipeInfo(eq(slug)) } returns Result.success(Unit)
createSubject().refreshRecipeInfo(slug).asFlow().first()
coVerify { recipeRepo.refreshRecipeInfo(slug) }
fun `when recipe is clicked expect call to repo`() = runTest {
coEvery { recipeRepo.refreshRecipeInfo(eq("cake")) } returns Result.success(Unit)
val subject = createSubject()
val recipe = RecipeListItemState(
imageUrl = null,
showFavoriteIcon = true,
entity = CAKE_RECIPE_SUMMARY_ENTITY,
)
subject.onEvent(RecipeListEvent.RecipeClick(recipe))
coVerify { recipeRepo.refreshRecipeInfo("cake") }
}
@Test
fun `when refreshRecipeInfo fails expect result with error`() = runTest {
val slug = "cake"
val result = Result.failure<Unit>(RuntimeException())
coEvery { recipeRepo.refreshRecipeInfo(eq(slug)) } returns result
val actual = createSubject().refreshRecipeInfo(slug).asFlow().first()
assertThat(actual).isEqualTo(result)
fun `when recipe is clicked and refresh succeeds expect id to open`() = runTest {
coEvery { recipeRepo.refreshRecipeInfo(eq("cake")) } returns Result.success(Unit)
val subject = createSubject()
val recipe = RecipeListItemState(
imageUrl = null,
showFavoriteIcon = true,
entity = CAKE_RECIPE_SUMMARY_ENTITY,
)
subject.onEvent(RecipeListEvent.RecipeClick(recipe))
assertThat(subject.screenState.value.recipeIdToOpen).isEqualTo("1")
}
@Test
fun `when recipe is clicked and refresh fails expect id to open`() = runTest {
coEvery { recipeRepo.refreshRecipeInfo(eq("cake")) } returns Result.failure(IOException())
val subject = createSubject()
val recipe = RecipeListItemState(
imageUrl = null,
showFavoriteIcon = true,
entity = CAKE_RECIPE_SUMMARY_ENTITY,
)
subject.onEvent(RecipeListEvent.RecipeClick(recipe))
assertThat(subject.screenState.value.recipeIdToOpen).isEqualTo("1")
}
@Test
fun `when delete recipe expect successful result in flow`() = runTest {
coEvery { recipeRepo.deleteRecipe(any()) } returns Result.success(Unit)
val subject = createSubject()
val results = runTestAndCollectFlow(subject.deleteRecipeResult) {
subject.onDeleteConfirm(CAKE_RECIPE_SUMMARY_ENTITY)
}
assertThat(results.single().isSuccess).isTrue()
}
@Test
fun `when delete recipe expect failed result in flow`() = runTest {
coEvery { recipeRepo.deleteRecipe(any()) } returns Result.failure(IOException())
val subject = createSubject()
val results = runTestAndCollectFlow(subject.deleteRecipeResult) {
subject.onDeleteConfirm(CAKE_RECIPE_SUMMARY_ENTITY)
}
assertThat(results.single().isFailure).isTrue()
}
private inline fun <T> TestScope.runTestAndCollectFlow(
flow: Flow<T>,
block: () -> Unit,
): List<T> {
val results = mutableListOf<T>()
val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) {
flow.toList(results)
}
block()
collectJob.cancel()
return results
val recipe = RecipeListItemState(
imageUrl = null,
showFavoriteIcon = true,
entity = CAKE_RECIPE_SUMMARY_ENTITY,
)
subject.onEvent(RecipeListEvent.DeleteConfirmed(recipe))
assertThat(subject.screenState.value.snackbarState).isEqualTo(RecipeListSnackbar.DeleteFailed)
}
private fun createSubject() = RecipesListViewModel(

View File

@@ -1,5 +1,6 @@
package gq.kirmanak.mealient.ui.recipes.info
import androidx.lifecycle.SavedStateHandle
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProvider
@@ -58,8 +59,11 @@ class RecipeInfoViewModelTest : BaseUnitTest() {
}
private fun createSubject(): RecipeInfoViewModel {
val argument = RecipeInfoFragmentArgs(RECIPE_ID).toSavedStateHandle()
return RecipeInfoViewModel(recipeRepo, logger, recipeImageUrlProvider, argument)
val savedStateHandle = SavedStateHandle(
mapOf("recipeId" to RECIPE_ID)
)
return RecipeInfoViewModel(recipeRepo, logger, recipeImageUrlProvider, savedStateHandle)
}
companion object {

View File

@@ -23,9 +23,7 @@ data class AddRecipeIngredient(
other as AddRecipeIngredient
if (note != other.note) return false
return true
return note == other.note
}
override fun hashCode(): Int {
@@ -46,9 +44,7 @@ data class AddRecipeInstruction(
other as AddRecipeInstruction
if (text != other.text) return false
if (ingredientReferences != other.ingredientReferences) return false
return true
return ingredientReferences == other.ingredientReferences
}
override fun hashCode(): Int {

View File

@@ -12,6 +12,10 @@ android {
namespace = "gq.kirmanak.mealient.shopping_list"
}
ksp {
arg("compose-destinations.generateNavGraphs", "false")
}
dependencies {
implementation(project(":architecture"))
implementation(project(":logging"))

View File

@@ -59,8 +59,10 @@ import gq.kirmanak.mealient.shopping_list.R
import gq.kirmanak.mealient.shopping_lists.ui.composables.getErrorMessage
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.Dimens
import gq.kirmanak.mealient.ui.components.BaseScreen
import gq.kirmanak.mealient.ui.components.LazyColumnWithLoadingState
import gq.kirmanak.mealient.ui.preview.ColorSchemePreview
import gq.kirmanak.mealient.ui.util.LoadingState
import gq.kirmanak.mealient.ui.util.data
import gq.kirmanak.mealient.ui.util.error
import gq.kirmanak.mealient.ui.util.map
@@ -71,7 +73,6 @@ data class ShoppingListNavArgs(
val shoppingListId: String,
)
@OptIn(ExperimentalMaterial3Api::class)
@Destination(
navArgsDelegate = ShoppingListNavArgs::class,
)
@@ -80,12 +81,50 @@ internal fun ShoppingListScreen(
shoppingListViewModel: ShoppingListViewModel = hiltViewModel(),
) {
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,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ShoppingListScreen(
loadingState: LoadingState<ShoppingListScreenState>,
errorToShowInSnackbar: Throwable?,
onSnackbarShown: () -> Unit,
onRefreshRequest: () -> Unit,
onAddItemClicked: () -> Unit,
onEditCancel: (ShoppingListItemState.ExistingItem) -> Unit,
onEditConfirm: (ShoppingListItemState.ExistingItem, ShoppingListItemEditorState) -> Unit,
onItemCheckedChange: (ShoppingListItemState.ExistingItem, Boolean) -> Unit,
onDeleteItem: (ShoppingListItemState.ExistingItem) -> Unit,
onEditStart: (ShoppingListItemState.ExistingItem) -> Unit,
onAddCancel: (ShoppingListItemState.NewItem) -> Unit,
onAddConfirm: (ShoppingListItemState.NewItem) -> Unit,
modifier: Modifier = Modifier,
) {
val defaultEmptyListError = stringResource(
R.string.shopping_list_screen_empty_list,
loadingState.data?.name.orEmpty()
)
LazyColumnWithLoadingState(
modifier = modifier,
loadingState = loadingState.map { it.items },
emptyListError = loadingState.error?.let { getErrorMessage(it) } ?: defaultEmptyListError,
retryButtonText = stringResource(id = R.string.shopping_lists_screen_empty_button_refresh),
@@ -96,11 +135,11 @@ internal fun ShoppingListScreen(
bottom = Dimens.Large * 4,
),
verticalArrangement = Arrangement.spacedBy(Dimens.Medium),
snackbarText = shoppingListViewModel.errorToShowInSnackbar?.let { getErrorMessage(error = it) },
onSnackbarShown = shoppingListViewModel::onSnackbarShown,
onRefresh = shoppingListViewModel::refreshShoppingList,
snackbarText = errorToShowInSnackbar?.let { getErrorMessage(error = it) },
onSnackbarShown = onSnackbarShown,
onRefresh = onRefreshRequest,
floatingActionButton = {
FloatingActionButton(onClick = shoppingListViewModel::onAddItemClicked) {
FloatingActionButton(onClick = onAddItemClicked) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(id = R.string.shopping_list_screen_add_icon_content_description),
@@ -122,31 +161,24 @@ internal fun ShoppingListScreen(
}
ShoppingListItemEditor(
state = state,
onEditCancelled = { shoppingListViewModel.onEditCancel(itemState) },
onEditConfirmed = {
shoppingListViewModel.onEditConfirm(
itemState,
state
)
}
onEditCancelled = { onEditCancel(itemState) },
onEditConfirmed = { onEditConfirm(itemState, state) },
)
} else {
ShoppingListItem(
itemState = itemState,
showDivider = index == firstCheckedItemIndex && index != 0,
modifier = Modifier.background(MaterialTheme.colorScheme.surface),
onCheckedChange = {
shoppingListViewModel.onItemCheckedChange(itemState, it)
},
onDismissed = { shoppingListViewModel.deleteShoppingListItem(itemState) },
onEditStart = { shoppingListViewModel.onEditStart(itemState) },
onCheckedChange = { onItemCheckedChange(itemState, it) },
onDismissed = { onDeleteItem(itemState) },
onEditStart = { onEditStart(itemState) },
)
}
} else if (itemState is ShoppingListItemState.NewItem) {
ShoppingListItemEditor(
state = itemState.item,
onEditCancelled = { shoppingListViewModel.onAddCancel(itemState) },
onEditConfirmed = { shoppingListViewModel.onAddConfirm(itemState) }
onEditCancelled = { onAddCancel(itemState) },
onEditConfirmed = { onAddConfirm(itemState) }
)
}
}
@@ -493,7 +525,7 @@ fun ShoppingListItem(
}
true
}
)
),
) {
val shoppingListItem = itemState.item
SwipeToDismiss(

View File

@@ -1,19 +0,0 @@
package gq.kirmanak.mealient.shopping_lists.ui.list
import androidx.compose.runtime.Composable
import com.ramcosta.composedestinations.DestinationsNavHost
import com.ramcosta.composedestinations.rememberNavHostEngine
import gq.kirmanak.mealient.shopping_lists.ui.NavGraphs
@Composable
fun MealientApp() {
val engine = rememberNavHostEngine()
val controller = engine.rememberNavController()
DestinationsNavHost(
navGraph = NavGraphs.root,
engine = engine,
navController = controller,
startRoute = NavGraphs.root.startRoute,
)
}

View File

@@ -1,47 +0,0 @@
package gq.kirmanak.mealient.shopping_lists.ui.list
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.fragment.app.Fragment
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.ui.ActivityUiStateController
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.CheckableMenuItem
import javax.inject.Inject
@AndroidEntryPoint
class ShoppingListsFragment : Fragment() {
@Inject
lateinit var activityUiStateController: ActivityUiStateController
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
AppTheme {
MealientApp()
}
}
}
}
override fun onResume() {
super.onResume()
activityUiStateController.updateUiState {
it.copy(
navigationVisible = true,
searchVisible = false,
checkedMenuItem = CheckableMenuItem.ShoppingLists,
)
}
}
}

View File

@@ -6,6 +6,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ShoppingCart
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
@@ -14,49 +16,55 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.navigation.navigate
import gq.kirmanak.mealient.datasource.models.GetShoppingListsSummaryResponse
import gq.kirmanak.mealient.shopping_list.R
import gq.kirmanak.mealient.shopping_lists.ui.composables.getErrorMessage
import gq.kirmanak.mealient.shopping_lists.ui.destinations.ShoppingListScreenDestination
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.Dimens
import gq.kirmanak.mealient.ui.components.BaseScreenState
import gq.kirmanak.mealient.ui.components.BaseScreenWithNavigation
import gq.kirmanak.mealient.ui.components.LazyColumnWithLoadingState
import gq.kirmanak.mealient.ui.preview.ColorSchemePreview
import gq.kirmanak.mealient.ui.util.error
@RootNavGraph(start = true)
@Destination(start = true)
@Destination
@Composable
fun ShoppingListsScreen(
navigator: DestinationsNavigator,
navController: NavController,
baseScreenState: BaseScreenState,
shoppingListsViewModel: ShoppingListsViewModel = hiltViewModel(),
) {
val loadingState by shoppingListsViewModel.loadingState.collectAsState()
val errorToShowInSnackbar = shoppingListsViewModel.errorToShowInSnackBar
LazyColumnWithLoadingState(
loadingState = loadingState,
emptyListError = loadingState.error?.let { getErrorMessage(it) }
?: stringResource(R.string.shopping_lists_screen_empty),
retryButtonText = stringResource(id = R.string.shopping_lists_screen_empty_button_refresh),
snackbarText = errorToShowInSnackbar?.let { getErrorMessage(error = it) },
onSnackbarShown = shoppingListsViewModel::onSnackbarShown,
onRefresh = shoppingListsViewModel::refresh
) { items ->
items(items) { shoppingList ->
ShoppingListCard(
shoppingList = shoppingList,
onItemClick = { clickedEntity ->
val shoppingListId = clickedEntity.id
navigator.navigate(ShoppingListScreenDestination(shoppingListId))
}
)
BaseScreenWithNavigation(
baseScreenState = baseScreenState,
) { modifier ->
LazyColumnWithLoadingState(
modifier = modifier,
loadingState = loadingState,
emptyListError = loadingState.error?.let { getErrorMessage(it) }
?: stringResource(R.string.shopping_lists_screen_empty),
retryButtonText = stringResource(id = R.string.shopping_lists_screen_empty_button_refresh),
snackbarText = errorToShowInSnackbar?.let { getErrorMessage(error = it) },
onSnackbarShown = shoppingListsViewModel::onSnackbarShown,
onRefresh = shoppingListsViewModel::refresh
) { items ->
items(items) { shoppingList ->
ShoppingListCard(
shoppingList = shoppingList,
onItemClick = { clickedEntity ->
val shoppingListId = clickedEntity.id
navController.navigate(ShoppingListScreenDestination(shoppingListId))
}
)
}
}
}
}
@@ -88,7 +96,7 @@ private fun ShoppingListCard(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = painterResource(id = R.drawable.ic_shopping_cart),
imageVector = Icons.Default.ShoppingCart,
contentDescription = stringResource(id = R.string.shopping_lists_screen_cart_icon),
modifier = Modifier.height(Dimens.Large),
)

View File

@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:tint="?attr/colorPrimary"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="@android:color/white"
android:pathData="M14.35,43.95Q12.85,43.95 11.8,42.9Q10.75,41.85 10.75,40.35Q10.75,38.85 11.8,37.8Q12.85,36.75 14.35,36.75Q15.85,36.75 16.9,37.8Q17.95,38.85 17.95,40.35Q17.95,41.85 16.9,42.9Q15.85,43.95 14.35,43.95ZM34.35,43.95Q32.85,43.95 31.8,42.9Q30.75,41.85 30.75,40.35Q30.75,38.85 31.8,37.8Q32.85,36.75 34.35,36.75Q35.85,36.75 36.9,37.8Q37.95,38.85 37.95,40.35Q37.95,41.85 36.9,42.9Q35.85,43.95 34.35,43.95ZM11.75,10.95 L17.25,22.35H31.65Q31.65,22.35 31.65,22.35Q31.65,22.35 31.65,22.35L37.9,10.95Q37.9,10.95 37.9,10.95Q37.9,10.95 37.9,10.95ZM10.25,7.95H39.7Q40.85,7.95 41.45,9Q42.05,10.05 41.45,11.1L34.7,23.25Q34.15,24.2 33.275,24.775Q32.4,25.35 31.35,25.35H16.2L13.4,30.55Q13.4,30.55 13.4,30.55Q13.4,30.55 13.4,30.55H37.95V33.55H13.85Q11.75,33.55 10.825,32.15Q9.9,30.75 10.85,29L14.05,23.1L6.45,7H2.55V4H8.4ZM17.25,22.35H31.65Q31.65,22.35 31.65,22.35Q31.65,22.35 31.65,22.35Z" />
</vector>

View File

@@ -110,6 +110,8 @@ androidx-compose-runtime-livedata = { group = "androidx.compose.runtime", name =
androidx-compose-material = { group = "androidx.compose.material", name = "material", version.ref = "materialCompose" }
androidx-compose-animation = { group = "androidx.compose.animation", name = "animation-graphics" }
google-accompanist-themeadapter-material3 = { group = "com.google.accompanist", name = "accompanist-themeadapter-material3", version.ref = "accompanistVersion" }
google-dagger-hiltPlugin = { group = "com.google.dagger", name = "hilt-android-gradle-plugin", version.ref = "hilt" }
@@ -196,7 +198,7 @@ chuckerteam-chucker = { group = "com.github.chuckerteam.chucker", name = "librar
kaspersky-kaspresso = { group = "com.kaspersky.android-components", name = "kaspresso", version.ref = "kaspresso" }
kaspersky-kaspresso-compose = { group = "com.kaspersky.android-components", name = "kaspresso-compose-support", version.ref = "kaspresso" }
composeDestinations-core = { group = "io.github.raamcosta.compose-destinations", name = "core", version.ref = "composeDestinations" }
composeDestinations-core = { group = "io.github.raamcosta.compose-destinations", name = "animations-core", version.ref = "composeDestinations" }
composeDestinations-ksp = { group = "io.github.raamcosta.compose-destinations", name = "ksp", version.ref = "composeDestinations" }
ktor-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }

View File

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

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