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 { dependencies {
implementation(project(":architecture")) implementation(project(":architecture"))
@@ -112,6 +116,8 @@ dependencies {
implementation(libs.androidx.shareTarget) implementation(libs.androidx.shareTarget)
implementation(libs.androidx.compose.materialIconsExtended)
implementation(libs.google.dagger.hiltAndroid) implementation(libs.google.dagger.hiltAndroid)
kapt(libs.google.dagger.hiltCompiler) kapt(libs.google.dagger.hiltCompiler)
kaptTest(libs.google.dagger.hiltAndroidCompiler) kaptTest(libs.google.dagger.hiltAndroidCompiler)
@@ -132,6 +138,10 @@ dependencies {
implementation(libs.coil) implementation(libs.coil)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.androidx.compose.animation)
implementation(libs.androidx.hilt.navigationCompose)
testImplementation(libs.junit) testImplementation(libs.junit)
implementation(libs.jetbrains.kotlinx.coroutinesAndroid) implementation(libs.jetbrains.kotlinx.coroutinesAndroid)

View File

@@ -1,9 +1,11 @@
package gq.kirmanak.mealient package gq.kirmanak.mealient
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import gq.kirmanak.mealient.screen.AuthenticationScreen
import gq.kirmanak.mealient.screen.BaseUrlScreen import gq.kirmanak.mealient.screen.BaseUrlScreen
import gq.kirmanak.mealient.screen.DisclaimerScreen import gq.kirmanak.mealient.screen.DisclaimerScreen
import gq.kirmanak.mealient.screen.RecipesListScreen 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 io.github.kakaocup.kakao.common.utilities.getResourceString
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@@ -13,63 +15,137 @@ class FirstSetUpTest : BaseTestCase() {
@Before @Before
fun dispatchUrls() { fun dispatchUrls() {
mockWebServer.dispatch { url, _ -> mockWebServer.dispatch { request ->
if (url == "/api/app/about") versionV1Response else notFoundResponse 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 @Test
fun test() = run { fun test() = run {
step("Ensure button is disabled") { step("Disclaimer screen with disabled button") {
DisclaimerScreen { onComposeScreen<DisclaimerScreen>(mainActivityRule) {
okayButton { okayButton {
isVisible() assertIsNotEnabled()
isDisabled() }
hasAnyText()
okayButtonText {
assertTextContains(getResourceString(R.string.fragment_disclaimer_button_okay))
} }
disclaimerText { disclaimerText {
isVisible() assertTextEquals(getResourceString(R.string.fragment_disclaimer_main_text))
hasText(R.string.fragment_disclaimer_main_text)
} }
} }
} }
step("Close disclaimer screen") { step("Close disclaimer screen") {
DisclaimerScreen { onComposeScreen<DisclaimerScreen>(mainActivityRule) {
okayButtonText {
assertTextEquals(getResourceString(R.string.fragment_disclaimer_button_okay))
}
okayButton { okayButton {
isVisible() assertIsEnabled()
isEnabled() performClick()
hasText(R.string.fragment_disclaimer_button_okay)
click()
} }
} }
} }
step("Enter mock server address and click proceed") { step("Enter mock server address and click proceed") {
BaseUrlScreen { onComposeScreen<BaseUrlScreen>(mainActivityRule) {
progressBar { progressBar {
isGone() assertDoesNotExist()
} }
urlInput { urlInput {
isVisible() performTextInput(mockWebServer.url("/").toString())
edit.replaceText(mockWebServer.url("/").toString()) }
hasHint(R.string.fragment_authentication_input_hint_url) urlInputLabel {
assertTextEquals(getResourceString(R.string.fragment_authentication_input_hint_url))
}
proceedButtonText {
assertTextEquals(getResourceString(R.string.fragment_base_url_save))
} }
proceedButton { proceedButton {
isVisible() assertIsEnabled()
isEnabled() performClick()
hasText(R.string.fragment_base_url_save)
click()
} }
} }
} }
step("Check that empty list of recipes is shown") { step("Check that authentication is shown") {
RecipesListScreen(mainActivityRule).apply { onComposeScreen<AuthenticationScreen>(mainActivityRule) {
errorText { 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() 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 package gq.kirmanak.mealient.screen
import com.kaspersky.kaspresso.screens.KScreen import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import gq.kirmanak.mealient.R import io.github.kakaocup.compose.node.element.ComposeScreen
import gq.kirmanak.mealient.ui.baseurl.BaseURLFragment import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.edit.KTextInputLayout
import io.github.kakaocup.kakao.progress.KProgressBar
import io.github.kakaocup.kakao.text.KButton
object BaseUrlScreen : KScreen<BaseUrlScreen>() { class BaseUrlScreen(
override val layoutId = R.layout.fragment_base_url semanticsProvider: SemanticsNodeInteractionsProvider,
override val viewClass = BaseURLFragment::class.java ) : 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 package gq.kirmanak.mealient.screen
import com.kaspersky.kaspresso.screens.KScreen import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import gq.kirmanak.mealient.R import io.github.kakaocup.compose.node.element.ComposeScreen
import gq.kirmanak.mealient.ui.disclaimer.DisclaimerFragment import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.text.KButton
import io.github.kakaocup.kakao.text.KTextView
object DisclaimerScreen : KScreen<DisclaimerScreen>() { internal class DisclaimerScreen(
override val layoutId = R.layout.fragment_disclaimer semanticsProvider: SemanticsNodeInteractionsProvider,
override val viewClass = DisclaimerFragment::class.java ) : 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 package gq.kirmanak.mealient.screen
import androidx.activity.ComponentActivity import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.printToLog
import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.compose.node.element.KNode
import org.junit.rules.TestRule
class RecipesListScreen<R : TestRule, A : ComponentActivity>( internal class RecipesListScreen(
semanticsProvider: AndroidComposeTestRule<R, A>, semanticsProvider: SemanticsNodeInteractionsProvider,
) : ComposeScreen<RecipesListScreen<R, A>>(semanticsProvider) { ) : ComposeScreen<RecipesListScreen>(semanticsProvider) {
init { val openDrawerButton = child<KNode> { hasTestTag("open-drawer-button") }
semanticsProvider.onRoot(useUnmergedTree = true).printToLog("RecipesListScreen")
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 package gq.kirmanak.mealient.data.baseurl
import kotlinx.coroutines.flow.Flow
interface ServerInfoRepo { interface ServerInfoRepo {
val baseUrlFlow: Flow<String?>
suspend fun getUrl(): String? suspend fun getUrl(): String?
suspend fun tryBaseURL(baseURL: String): Result<Unit> 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.datasource.ServerUrlProvider
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject import javax.inject.Inject
class ServerInfoRepoImpl @Inject constructor( class ServerInfoRepoImpl @Inject constructor(
@@ -10,6 +11,9 @@ class ServerInfoRepoImpl @Inject constructor(
private val logger: Logger, private val logger: Logger,
) : ServerInfoRepo, ServerUrlProvider { ) : ServerInfoRepo, ServerUrlProvider {
override val baseUrlFlow: Flow<String?>
get() = serverInfoStorage.baseUrlFlow
override suspend fun getUrl(): String? { override suspend fun getUrl(): String? {
val result = serverInfoStorage.getBaseURL() val result = serverInfoStorage.getBaseURL()
logger.v { "getUrl() returned: $result" } logger.v { "getUrl() returned: $result" }

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ package gq.kirmanak.mealient.data.baseurl.impl
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import gq.kirmanak.mealient.data.baseurl.ServerInfoStorage import gq.kirmanak.mealient.data.baseurl.ServerInfoStorage
import gq.kirmanak.mealient.data.storage.PreferencesStorage import gq.kirmanak.mealient.data.storage.PreferencesStorage
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject import javax.inject.Inject
class ServerInfoStorageImpl @Inject constructor( class ServerInfoStorageImpl @Inject constructor(
@@ -12,6 +13,9 @@ class ServerInfoStorageImpl @Inject constructor(
private val baseUrlKey: Preferences.Key<String> private val baseUrlKey: Preferences.Key<String>
get() = preferencesStorage.baseUrlKey get() = preferencesStorage.baseUrlKey
override val baseUrlFlow: Flow<String?>
get() = preferencesStorage.valueUpdates(baseUrlKey)
override suspend fun getBaseURL(): String? = getValue(baseUrlKey) override suspend fun getBaseURL(): String? = getValue(baseUrlKey)
override suspend fun storeBaseURL(baseURL: String?) { 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.SharedPreferences
import android.content.res.Configuration.UI_MODE_NIGHT_MASK import android.content.res.Configuration.UI_MODE_NIGHT_MASK
import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.content.res.Configuration.UI_MODE_NIGHT_YES
import android.content.res.Resources
import android.os.Build 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 android.widget.Toast
import androidx.annotation.StringRes 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 gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.channels.ChannelResult import kotlinx.coroutines.channels.ChannelResult
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.onClosed import kotlinx.coroutines.channels.onClosed
import kotlinx.coroutines.channels.onFailure import kotlinx.coroutines.channels.onFailure
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.callbackFlow 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> { fun <T> ChannelResult<T>.logErrors(methodName: String, logger: Logger): ChannelResult<T> {
onFailure { logger.e(it) { "$methodName: can't send event" } } 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 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( fun <T> SharedPreferences.prefsChangeFlow(
logger: Logger, logger: Logger,
valueReader: SharedPreferences.() -> T, valueReader: SharedPreferences.() -> T,
@@ -99,16 +32,6 @@ fun <T> SharedPreferences.prefsChangeFlow(
awaitClose { unregisterOnSharedPreferenceChangeListener(listener) } 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(text: String) = showToast(text, Toast.LENGTH_LONG)
fun Context.showLongToast(@StringRes text: Int) = showLongToast(getString(text)) 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() Toast.makeText(this, text, length).show()
} }
fun View.hideKeyboard() {
val imm = context.getSystemService<InputMethodManager>()
imm?.hideSoftInputFromWindow(windowToken, 0)
}
fun Context.isDarkThemeOn(): Boolean { fun Context.isDarkThemeOn(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) resources.configuration.isNightModeActive 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 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 package gq.kirmanak.mealient.ui.activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.MenuItem import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.core.content.FileProvider
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.isVisible import androidx.core.view.WindowInsetsControllerCompat
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 dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.NavGraphDirections.Companion.actionGlobalAddRecipeFragment import gq.kirmanak.mealient.extensions.isDarkThemeOn
import gq.kirmanak.mealient.NavGraphDirections.Companion.actionGlobalAuthenticationFragment import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.NavGraphDirections.Companion.actionGlobalBaseURLFragment import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.NavGraphDirections.Companion.actionGlobalRecipesListFragment import javax.inject.Inject
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"
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : BaseActivity<MainActivityBinding>( class MainActivity : ComponentActivity() {
binder = MainActivityBinding::bind,
containerId = R.id.drawer, @Inject
layoutRes = R.layout.main_activity, lateinit var logger: Logger
) {
private val viewModel by viewModels<MainActivityViewModel>() private val viewModel by viewModels<MainActivityViewModel>()
private val navController: NavController
get() = binding.navHost.getFragment<NavHostFragment>().navController
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen() val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
splashScreen.setKeepOnScreenCondition { viewModel.startDestination.value == null } logger.v { "onCreate() called with: savedInstanceState = $savedInstanceState" }
setupUi() with(WindowInsetsControllerCompat(window, window.decorView)) {
configureNavGraph() val isAppearanceLightBars = !isDarkThemeOn()
} isAppearanceLightNavigationBars = isAppearanceLightBars
isAppearanceLightStatusBars = isAppearanceLightBars
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)
} }
} splashScreen.setKeepOnScreenCondition {
viewModel.appState.value.forcedRoute == ForcedDestination.Undefined
private fun setupUi() {
binding.toolbar.setNavigationOnClickListener {
binding.toolbar.clearSearchFocus()
binding.drawer.open()
} }
binding.toolbar.onSearchQueryChanged { query -> setContent {
viewModel.onSearchQuery(query.trim().takeUnless { it.isEmpty() }) AppTheme {
} MealientApp(viewModel)
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
} }
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 package gq.kirmanak.mealient.ui.activity
import androidx.lifecycle.LiveData import android.app.Application
import androidx.lifecycle.MutableLiveData import android.content.Intent
import androidx.annotation.StringRes
import androidx.core.content.FileProvider
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel 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.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage
import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.ActivityUiState import gq.kirmanak.mealient.logging.getLogFile
import gq.kirmanak.mealient.ui.ActivityUiStateController import gq.kirmanak.mealient.ui.destinations.AuthenticationScreenDestination
import gq.kirmanak.mealient.ui.baseurl.BaseURLFragmentArgs import gq.kirmanak.mealient.ui.destinations.BaseURLScreenDestination
import kotlinx.coroutines.channels.Channel import gq.kirmanak.mealient.ui.destinations.DirectionDestination
import kotlinx.coroutines.flow.Flow import gq.kirmanak.mealient.ui.destinations.DisclaimerScreenDestination
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class MainActivityViewModel @Inject constructor( internal class MainActivityViewModel @Inject constructor(
private val application: Application,
private val authRepo: AuthRepo, private val authRepo: AuthRepo,
private val logger: Logger, private val logger: Logger,
private val disclaimerStorage: DisclaimerStorage, private val disclaimerStorage: DisclaimerStorage,
private val serverInfoRepo: ServerInfoRepo, private val serverInfoRepo: ServerInfoRepo,
private val recipeRepo: RecipeRepo,
private val activityUiStateController: ActivityUiStateController,
) : ViewModel() { ) : ViewModel() {
val uiState: StateFlow<ActivityUiState> = activityUiStateController.getUiStateFlow() private val _appState = MutableStateFlow(MealientAppState())
val appState: StateFlow<MealientAppState> get() = _appState.asStateFlow()
private val _startDestination = MutableLiveData<StartDestinationInfo>()
val startDestination: LiveData<StartDestinationInfo> = _startDestination
private val _clearSearchViewFocusChannel = Channel<Unit>()
val clearSearchViewFocus: Flow<Unit> = _clearSearchViewFocusChannel.receiveAsFlow()
init { init {
authRepo.isAuthorizedFlow checkForcedDestination()
.onEach { isAuthorized -> updateUiState { it.copy(isAuthorized = isAuthorized) } } }
.launchIn(viewModelScope)
private fun checkForcedDestination() {
logger.v { "checkForcedDestination() called" }
val baseUrlSetState = serverInfoRepo.baseUrlFlow.map { it != null }
val tokenExistsState = authRepo.isAuthorizedFlow
val disclaimerAcceptedState = disclaimerStorage.isDisclaimerAcceptedFlow
viewModelScope.launch { viewModelScope.launch {
_startDestination.value = when { combine(
!disclaimerStorage.isDisclaimerAccepted() -> { baseUrlSetState,
StartDestinationInfo(R.id.disclaimerFragment) tokenExistsState,
} disclaimerAcceptedState,
serverInfoRepo.getUrl() == null -> { ) { baseUrlSet, tokenExists, disclaimerAccepted ->
StartDestinationInfo(R.id.baseURLFragment, BaseURLFragmentArgs(true).toBundle()) logger.d { "baseUrlSet = $baseUrlSet, tokenExists = $tokenExists, disclaimerAccepted = $disclaimerAccepted" }
} when {
else -> { !disclaimerAccepted -> ForcedDestination.Destination(DisclaimerScreenDestination)
StartDestinationInfo(R.id.recipesListFragment) !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) { fun onEvent(event: AppEvent) {
activityUiStateController.updateUiState(updater) 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() { private fun logEmailIntent(): Intent? {
logger.v { "logout() called" } val logFileUri = try {
viewModelScope.launch { authRepo.logout() } 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?) { private fun logoutConfirmationDialog() = DialogState(
logger.v { "onSearchQuery() called with: query = $query" } title = R.string.activity_main_logout_confirmation_title,
recipeRepo.updateNameQuery(query) 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() { private fun emailConfirmationDialog() = DialogState(
logger.v { "clearSearchViewFocus() called" } title = R.string.activity_main_email_logs_confirmation_title,
_clearSearchViewFocusChannel.trySend(Unit) 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 package gq.kirmanak.mealient.ui.add
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.add.AddRecipeRepo import gq.kirmanak.mealient.data.add.AddRecipeRepo
import gq.kirmanak.mealient.datasource.models.AddRecipeInfo 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.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class AddRecipeViewModel @Inject constructor( internal class AddRecipeViewModel @Inject constructor(
private val addRecipeRepo: AddRecipeRepo, private val addRecipeRepo: AddRecipeRepo,
private val logger: Logger, private val logger: Logger,
) : ViewModel() { ) : ViewModel() {
private val _addRecipeResultChannel = Channel<Boolean>(Channel.UNLIMITED) private val _screenState = MutableStateFlow(AddRecipeScreenState())
val addRecipeResult: Flow<Boolean> get() = _addRecipeResultChannel.receiveAsFlow() val screenState: StateFlow<AddRecipeScreenState> get() = _screenState.asStateFlow()
private val _preservedAddRecipeRequestChannel = Channel<AddRecipeInfo>(Channel.UNLIMITED) init {
val preservedAddRecipeRequest: Flow<AddRecipeInfo>
get() = _preservedAddRecipeRequestChannel.receiveAsFlow()
fun loadPreservedRequest() {
logger.v { "loadPreservedRequest() called" }
viewModelScope.launch { doLoadPreservedRequest() } viewModelScope.launch { doLoadPreservedRequest() }
} }
private suspend fun doLoadPreservedRequest() { @VisibleForTesting
suspend fun doLoadPreservedRequest() {
logger.v { "doLoadPreservedRequest() called" } logger.v { "doLoadPreservedRequest() called" }
val request = addRecipeRepo.addRecipeRequestFlow.first() val request = addRecipeRepo.addRecipeRequestFlow.first()
logger.d { "doLoadPreservedRequest: request = $request" } logger.d { "doLoadPreservedRequest: request = $request" }
_preservedAddRecipeRequestChannel.send(request) _screenState.update { state ->
} state.copy(
recipeNameInput = request.name,
fun clear() { recipeDescriptionInput = request.description,
logger.v { "clear() called" } recipeYieldInput = request.recipeYield,
viewModelScope.launch { isPublicRecipe = request.settings.public,
addRecipeRepo.clear() disableComments = request.settings.disableComments,
doLoadPreservedRequest() ingredients = request.recipeIngredient.map { it.note },
instructions = request.recipeInstructions.map { it.text },
saveButtonEnabled = request.name.isNotBlank(),
)
} }
} }
fun preserve(request: AddRecipeInfo) { fun onEvent(event: AddRecipeScreenEvent) {
logger.v { "preserve() called with: request = $request" } logger.v { "onEvent() called with: event = $event" }
viewModelScope.launch { addRecipeRepo.preserve(request) } 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" } logger.v { "saveRecipe() called" }
_screenState.update {
it.copy(
isLoading = true,
saveButtonEnabled = false,
clearButtonEnabled = false,
)
}
viewModelScope.launch { viewModelScope.launch {
val isSuccessful = runCatchingExceptCancel { addRecipeRepo.saveRecipe() } val isSuccessful = runCatchingExceptCancel { addRecipeRepo.saveRecipe() }.isSuccess
.fold(onSuccess = { true }, onFailure = { false }) _screenState.update {
logger.d { "saveRecipe: isSuccessful = $isSuccessful" } it.copy(
_addRecipeResultChannel.send(isSuccessful) 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 package gq.kirmanak.mealient.ui.auth
import androidx.lifecycle.LiveData import android.app.Application
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.datasource.NetworkError
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger 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 kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class AuthenticationViewModel @Inject constructor( internal class AuthenticationViewModel @Inject constructor(
private val application: Application,
private val authRepo: AuthRepo, private val authRepo: AuthRepo,
private val logger: Logger, private val logger: Logger,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableLiveData<OperationUiState<Unit>>(OperationUiState.Initial()) private val _screenState = MutableStateFlow(AuthenticationScreenState())
val uiState: LiveData<OperationUiState<Unit>> get() = _uiState val screenState = _screenState.asStateFlow()
fun authenticate(email: String, password: String) { fun onEvent(event: AuthenticationScreenEvent) {
logger.v { "authenticate() called" } logger.v { "onEvent() called with: event = $event" }
_uiState.value = OperationUiState.Progress() when (event) {
viewModelScope.launch { is AuthenticationScreenEvent.OnLoginClick -> {
val result = runCatchingExceptCancel { authRepo.authenticate(email, password) } onLoginClick()
logger.d { "Authentication result = $result" } }
_uiState.value = OperationUiState.fromResult(result)
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 package gq.kirmanak.mealient.ui.baseurl
import androidx.lifecycle.LiveData import android.app.Application
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.data.baseurl.impl.BaseUrlLogRedactor 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.TrustedCertificatesStore
import gq.kirmanak.mealient.datasource.findCauseAsInstanceOf import gq.kirmanak.mealient.datasource.findCauseAsInstanceOf
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.OperationUiState import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.security.cert.X509Certificate import java.security.cert.X509Certificate
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class BaseURLViewModel @Inject constructor( internal class BaseURLViewModel @Inject constructor(
private val application: Application,
private val serverInfoRepo: ServerInfoRepo, private val serverInfoRepo: ServerInfoRepo,
private val authRepo: AuthRepo, private val authRepo: AuthRepo,
private val recipeRepo: RecipeRepo, private val recipeRepo: RecipeRepo,
private val logger: Logger, private val logger: Logger,
private val trustedCertificatesStore: TrustedCertificatesStore, private val trustedCertificatesStore: TrustedCertificatesStore,
private val baseUrlLogRedactor: BaseUrlLogRedactor, private val baseUrlLogRedactor: BaseUrlLogRedactor,
) : ViewModel() { ) : AndroidViewModel(application) {
private val _uiState = MutableLiveData<OperationUiState<Unit>>(OperationUiState.Initial()) private val _screenState = MutableStateFlow(BaseURLScreenState())
val uiState: LiveData<OperationUiState<Unit>> get() = _uiState val screenState = _screenState.asStateFlow()
private val invalidCertificatesChannel = Channel<X509Certificate>(Channel.UNLIMITED) init {
val invalidCertificatesFlow = invalidCertificatesChannel.receiveAsFlow() 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) { fun saveBaseUrl(baseURL: String) {
logger.v { "saveBaseUrl() called" } logger.v { "saveBaseUrl() called" }
_uiState.value = OperationUiState.Progress() _screenState.update {
it.copy(
isLoading = true,
errorText = null,
invalidCertificateDialogState = null,
isButtonEnabled = false,
)
}
viewModelScope.launch { checkBaseURL(baseURL) } viewModelScope.launch { checkBaseURL(baseURL) }
} }
@@ -55,7 +73,7 @@ class BaseURLViewModel @Inject constructor(
logger.d { "checkBaseURL: Created URL = \"$url\", with prefix = \"$urlWithPrefix\"" } logger.d { "checkBaseURL: Created URL = \"$url\", with prefix = \"$urlWithPrefix\"" }
if (url == serverInfoRepo.getUrl()) { if (url == serverInfoRepo.getUrl()) {
logger.d { "checkBaseURL: new URL matches current" } logger.d { "checkBaseURL: new URL matches current" }
_uiState.value = OperationUiState.fromResult(Result.success(Unit)) displayCheckUrlSuccess()
return return
} }
@@ -63,7 +81,6 @@ class BaseURLViewModel @Inject constructor(
logger.e(it) { "checkBaseURL: trying to recover, had prefix = $hasPrefix" } logger.e(it) { "checkBaseURL: trying to recover, had prefix = $hasPrefix" }
val certificateError = it.findCauseAsInstanceOf<CertificateCombinedException>() val certificateError = it.findCauseAsInstanceOf<CertificateCombinedException>()
if (certificateError != null) { if (certificateError != null) {
invalidCertificatesChannel.send(certificateError.serverCert)
throw certificateError throw certificateError
} else if (hasPrefix || it is NetworkError.NotMealie) { } else if (hasPrefix || it is NetworkError.NotMealie) {
throw it throw it
@@ -79,11 +96,113 @@ class BaseURLViewModel @Inject constructor(
} }
logger.i { "checkBaseURL: result is $result" } 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" } logger.v { "acceptInvalidCertificate() called with: certificate = $certificate" }
trustedCertificatesStore.addTrustedCertificate(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 package gq.kirmanak.mealient.ui.disclaimer
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.lifecycle.* import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.delay 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.flow
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.take
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class DisclaimerViewModel @Inject constructor( internal class DisclaimerViewModel @Inject constructor(
private val disclaimerStorage: DisclaimerStorage, private val disclaimerStorage: DisclaimerStorage,
private val logger: Logger, private val logger: Logger,
) : ViewModel() { ) : 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 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() { fun acceptDisclaimer() {
logger.v { "acceptDisclaimer() called" } logger.v { "acceptDisclaimer() called" }
viewModelScope.launch { disclaimerStorage.acceptDisclaimer() } viewModelScope.launch { disclaimerStorage.acceptDisclaimer() }
@@ -37,7 +62,7 @@ class DisclaimerViewModel @Inject constructor(
isCountDownStarted = true isCountDownStarted = true
tickerFlow(COUNT_DOWN_TICK_PERIOD_SEC.toLong(), TimeUnit.SECONDS) tickerFlow(COUNT_DOWN_TICK_PERIOD_SEC.toLong(), TimeUnit.SECONDS)
.take(FULL_COUNT_DOWN_SEC - COUNT_DOWN_TICK_PERIOD_SEC + 1) .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) .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.RecipeInstructionEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeWithSummaryAndIngredientsAndInstructions import gq.kirmanak.mealient.database.recipe.entity.RecipeWithSummaryAndIngredientsAndInstructions
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.navArgs
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
@@ -17,14 +18,15 @@ import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class RecipeInfoViewModel @Inject constructor( internal class RecipeInfoViewModel @Inject constructor(
private val recipeRepo: RecipeRepo, private val recipeRepo: RecipeRepo,
private val logger: Logger, private val logger: Logger,
private val recipeImageUrlProvider: RecipeImageUrlProvider, private val recipeImageUrlProvider: RecipeImageUrlProvider,
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
) : ViewModel() { ) : ViewModel() {
private val args = RecipeInfoFragmentArgs.fromSavedStateHandle(savedStateHandle) private val args = savedStateHandle.navArgs<RecipeScreenArgs>()
private val _uiState = flow { private val _uiState = flow {
logger.v { "Initializing UI state with args = $args" } logger.v { "Initializing UI state with args = $args" }
val recipeInfo = recipeRepo.loadRecipeInfo(args.recipeId) 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.Arrangement
import androidx.compose.foundation.layout.Column 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.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.AppTheme
import gq.kirmanak.mealient.ui.Dimens import gq.kirmanak.mealient.ui.Dimens
import gq.kirmanak.mealient.ui.components.BaseScreen
import gq.kirmanak.mealient.ui.preview.ColorSchemePreview import gq.kirmanak.mealient.ui.preview.ColorSchemePreview
@OptIn(ExperimentalLayoutApi::class) data class RecipeScreenArgs(
val recipeId: String,
)
@Destination(
navArgsDelegate = RecipeScreenArgs::class,
)
@Composable @Composable
fun RecipeScreen( internal fun RecipeScreen(
uiState: RecipeInfoUiState, 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() KeepScreenOn()
Scaffold { padding -> Column(
Column( modifier = modifier
modifier = Modifier .verticalScroll(
.verticalScroll( state = rememberScrollState(),
state = rememberScrollState(), ),
) verticalArrangement = Arrangement.spacedBy(Dimens.Small, Alignment.Top),
.padding(padding) ) {
.consumeWindowInsets(padding), HeaderSection(
verticalArrangement = Arrangement.spacedBy(Dimens.Small, Alignment.Top), imageUrl = state.imageUrl,
) { title = state.title,
HeaderSection( description = state.description,
imageUrl = uiState.imageUrl, )
title = uiState.title,
description = uiState.description, if (state.showIngredients) {
IngredientsSection(
ingredients = state.recipeIngredients,
) )
}
if (uiState.showIngredients) { if (state.showInstructions) {
IngredientsSection( InstructionsSection(
ingredients = uiState.recipeIngredients, instructions = state.recipeInstructions,
) )
}
if (uiState.showInstructions) {
InstructionsSection(
instructions = uiState.recipeInstructions,
)
}
} }
} }
} }
@@ -58,7 +76,7 @@ fun RecipeScreen(
private fun RecipeScreenPreview() { private fun RecipeScreenPreview() {
AppTheme { AppTheme {
RecipeScreen( RecipeScreen(
uiState = RecipeInfoUiState( state = RecipeInfoUiState(
showIngredients = true, showIngredients = true,
showInstructions = true, showInstructions = true,
summaryEntity = SUMMARY_ENTITY, 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.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape 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.Card
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -103,7 +107,7 @@ private fun RecipeHeader(
onClick = onDeleteClick, onClick = onDeleteClick,
) { ) {
Icon( Icon(
painter = painterResource(id = R.drawable.ic_delete), imageVector = Icons.Default.Delete,
contentDescription = stringResource(id = R.string.view_holder_recipe_delete_content_description), contentDescription = stringResource(id = R.string.view_holder_recipe_delete_content_description),
) )
} }
@@ -112,15 +116,17 @@ private fun RecipeHeader(
IconButton( IconButton(
onClick = onFavoriteClick, onClick = onFavoriteClick,
) { ) {
val resource = if (recipe.entity.isFavorite) {
R.drawable.ic_favorite_filled
} else {
R.drawable.ic_favorite_unfilled
}
Icon( Icon(
painter = painterResource(id = resource), imageVector = if (recipe.entity.isFavorite) {
contentDescription = stringResource(id = R.string.view_holder_recipe_favorite_content_description), 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 package gq.kirmanak.mealient.ui.recipes.list
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.PaddingValues 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.DrawerState
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -17,43 +20,83 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import androidx.paging.LoadState import androidx.paging.LoadState
import androidx.paging.PagingData
import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemContentType import androidx.paging.compose.itemContentType
import androidx.paging.compose.itemKey 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.R
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.Dimens 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.CenteredProgressIndicator
import gq.kirmanak.mealient.ui.components.LazyPagingColumnPullRefresh import gq.kirmanak.mealient.ui.components.LazyPagingColumnPullRefresh
import kotlinx.coroutines.flow.Flow import gq.kirmanak.mealient.ui.components.OpenDrawerIconButton
import kotlinx.coroutines.flow.StateFlow import gq.kirmanak.mealient.ui.destinations.RecipeScreenDestination
import gq.kirmanak.mealient.ui.preview.ColorSchemePreview
@OptIn(ExperimentalLayoutApi::class)
@Destination
@Composable @Composable
internal fun RecipesList( internal fun RecipesList(
recipesFlow: Flow<PagingData<RecipeListItemState>>, navController: NavController,
onDeleteClick: (RecipeListItemState) -> Unit, baseScreenState: BaseScreenState,
onFavoriteClick: (RecipeListItemState) -> Unit, viewModel: RecipesListViewModel = hiltViewModel(),
onItemClick: (RecipeListItemState) -> Unit,
onSnackbarShown: () -> Unit,
snackbarMessageState: StateFlow<RecipeListSnackbar?>,
) { ) {
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 val isRefreshing = recipes.loadState.refresh is LoadState.Loading
var itemToDelete: RecipeListItemState? by remember { mutableStateOf(null) } var itemToDelete: RecipeListItemState? by remember { mutableStateOf(null) }
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val snackbar: RecipeListSnackbar? by snackbarMessageState.collectAsState() val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
Scaffold( BaseScreenWithNavigation(
snackbarHost = { SnackbarHost(snackbarHostState) }, baseScreenState = baseScreenState,
) { padding -> drawerState = drawerState,
snackbar?.message?.let { message -> topAppBar = {
RecipesTopAppBar(
searchQuery = state.searchQuery,
onValueChanged = { onEvent(RecipeListEvent.SearchQueryChanged(it)) },
drawerState = drawerState,
)
},
snackbarHostState = snackbarHostState,
) { modifier ->
state.snackbarState?.message?.let { message ->
LaunchedEffect(message) { LaunchedEffect(message) {
snackbarHostState.showSnackbar(message) snackbarHostState.showSnackbar(message)
onSnackbarShown() onEvent(RecipeListEvent.SnackbarShown)
} }
} ?: run { } ?: run {
snackbarHostState.currentSnackbarData?.dismiss() snackbarHostState.currentSnackbarData?.dismiss()
@@ -63,36 +106,33 @@ internal fun RecipesList(
ConfirmDeleteDialog( ConfirmDeleteDialog(
onDismissRequest = { itemToDelete = null }, onDismissRequest = { itemToDelete = null },
onConfirm = { onConfirm = {
onDeleteClick(item) onEvent(RecipeListEvent.DeleteConfirmed(item))
itemToDelete = null itemToDelete = null
}, },
item = item, item = item,
) )
} }
val innerModifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
when { when {
recipes.itemCount != 0 -> { recipes.itemCount != 0 -> {
RecipesListData( RecipesListData(
modifier = innerModifier, modifier = modifier,
recipes = recipes, recipes = recipes,
onDeleteClick = { itemToDelete = it }, onDeleteClick = { itemToDelete = it },
onFavoriteClick = onFavoriteClick, onFavoriteClick = { onEvent(RecipeListEvent.FavoriteClick(it)) },
onItemClick = onItemClick onItemClick = { onEvent(RecipeListEvent.RecipeClick(it)) },
) )
} }
isRefreshing -> { isRefreshing -> {
CenteredProgressIndicator( CenteredProgressIndicator(
modifier = innerModifier modifier = modifier
) )
} }
else -> { else -> {
RecipesListError( RecipesListError(
modifier = innerModifier, modifier = modifier,
recipes = recipes, recipes = recipes,
) )
} }
@@ -126,7 +166,7 @@ private fun RecipesListData(
recipes: LazyPagingItems<RecipeListItemState>, recipes: LazyPagingItems<RecipeListItemState>,
onDeleteClick: (RecipeListItemState) -> Unit, onDeleteClick: (RecipeListItemState) -> Unit,
onFavoriteClick: (RecipeListItemState) -> Unit, onFavoriteClick: (RecipeListItemState) -> Unit,
onItemClick: (RecipeListItemState) -> Unit onItemClick: (RecipeListItemState) -> Unit,
) { ) {
LazyPagingColumnPullRefresh( LazyPagingColumnPullRefresh(
modifier = modifier 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 package gq.kirmanak.mealient.ui.recipes.list
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.liveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.paging.cachedIn 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.data.recipes.impl.RecipeImageUrlProvider
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -26,6 +21,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -43,7 +39,7 @@ internal class RecipesListViewModel @Inject constructor(
private val showFavoriteIcon: StateFlow<Boolean> = private val showFavoriteIcon: StateFlow<Boolean> =
authRepo.isAuthorizedFlow.stateIn(viewModelScope, SharingStarted.Eagerly, false) authRepo.isAuthorizedFlow.stateIn(viewModelScope, SharingStarted.Eagerly, false)
val pagingDataRecipeState: Flow<PagingData<RecipeListItemState>> = private val pagingDataRecipeState: Flow<PagingData<RecipeListItemState>> =
pagingData.combine(showFavoriteIcon) { data, showFavorite -> pagingData.combine(showFavoriteIcon) { data, showFavorite ->
data.map { item -> data.map { item ->
val imageUrl = recipeImageUrlProvider.generateImageUrl(item.imageId) val imageUrl = recipeImageUrlProvider.generateImageUrl(item.imageId)
@@ -55,15 +51,10 @@ internal class RecipesListViewModel @Inject constructor(
} }
} }
private val _deleteRecipeResult = MutableSharedFlow<Result<Unit>>( private val _screenState = MutableStateFlow(
replay = 0, RecipeListState(pagingDataRecipeState = pagingDataRecipeState)
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
) )
val deleteRecipeResult: SharedFlow<Result<Unit>> get() = _deleteRecipeResult val screenState: StateFlow<RecipeListState> get() = _screenState.asStateFlow()
private val _snackbarState = MutableStateFlow<RecipeListSnackbar?>(null)
val snackbarState get() = _snackbarState.asStateFlow()
init { init {
authRepo.isAuthorizedFlow.valueUpdatesOnly().onEach { hasAuthorized -> authRepo.isAuthorizedFlow.valueUpdatesOnly().onEach { hasAuthorized ->
@@ -72,23 +63,23 @@ internal class RecipesListViewModel @Inject constructor(
}.launchIn(viewModelScope) }.launchIn(viewModelScope)
} }
fun refreshRecipeInfo(recipeSlug: String): LiveData<Result<Unit>> { private fun onRecipeClicked(entity: RecipeSummaryEntity) {
logger.v { "refreshRecipeInfo called with: recipeSlug = $recipeSlug" } logger.v { "onRecipeClicked() called with: entity = $entity" }
return liveData { viewModelScope.launch {
val result = recipeRepo.refreshRecipeInfo(recipeSlug) val result = recipeRepo.refreshRecipeInfo(entity.slug)
logger.v { "refreshRecipeInfo: emitting $result" } logger.d { "Recipe info refreshed: $result" }
emit(result) _screenState.update { it.copy(recipeIdToOpen = entity.remoteId) }
} }
} }
fun onFavoriteIconClick(recipeSummaryEntity: RecipeSummaryEntity) { private fun onFavoriteIconClick(recipeSummaryEntity: RecipeSummaryEntity) {
logger.v { "onFavoriteIconClick() called with: recipeSummaryEntity = $recipeSummaryEntity" } logger.v { "onFavoriteIconClick() called with: recipeSummaryEntity = $recipeSummaryEntity" }
viewModelScope.launch { viewModelScope.launch {
val result = recipeRepo.updateIsRecipeFavorite( val result = recipeRepo.updateIsRecipeFavorite(
recipeSlug = recipeSummaryEntity.slug, recipeSlug = recipeSummaryEntity.slug,
isFavorite = recipeSummaryEntity.isFavorite.not(), isFavorite = recipeSummaryEntity.isFavorite.not(),
) )
_snackbarState.value = result.fold( val snackbar = result.fold(
onSuccess = { isFavorite -> onSuccess = { isFavorite ->
val name = recipeSummaryEntity.name val name = recipeSummaryEntity.name
if (isFavorite) { if (isFavorite) {
@@ -101,23 +92,69 @@ internal class RecipesListViewModel @Inject constructor(
RecipeListSnackbar.FavoriteUpdateFailed RecipeListSnackbar.FavoriteUpdateFailed
} }
) )
_screenState.update { it.copy(snackbarState = snackbar) }
} }
} }
fun onDeleteConfirm(recipeSummaryEntity: RecipeSummaryEntity) { private fun onDeleteConfirm(recipeSummaryEntity: RecipeSummaryEntity) {
logger.v { "onDeleteConfirm() called with: recipeSummaryEntity = $recipeSummaryEntity" } logger.v { "onDeleteConfirm() called with: recipeSummaryEntity = $recipeSummaryEntity" }
viewModelScope.launch { viewModelScope.launch {
val result = recipeRepo.deleteRecipe(recipeSummaryEntity) val result = recipeRepo.deleteRecipe(recipeSummaryEntity)
logger.d { "onDeleteConfirm: delete result is $result" } logger.d { "onDeleteConfirm: delete result is $result" }
_deleteRecipeResult.emit(result) val snackbar = result.fold(
_snackbarState.value = result.fold(
onSuccess = { null }, onSuccess = { null },
onFailure = { RecipeListSnackbar.DeleteFailed }, onFailure = { RecipeListSnackbar.DeleteFailed },
) )
_screenState.update { it.copy(snackbarState = snackbar) }
} }
} }
fun onSnackbarShown() { private fun onSnackbarShown() {
_snackbarState.value = null _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 package gq.kirmanak.mealient.ui.share
import android.content.Intent import android.content.Intent
import android.graphics.drawable.Animatable2
import android.graphics.drawable.AnimatedVectorDrawable
import android.graphics.drawable.Drawable
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.core.view.isInvisible import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.postDelayed
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R 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.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 gq.kirmanak.mealient.ui.OperationUiState
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class ShareRecipeActivity : BaseActivity<ActivityShareRecipeBinding>( class ShareRecipeActivity : ComponentActivity() {
binder = ActivityShareRecipeBinding::bind,
containerId = R.id.root, @Inject
layoutRes = R.layout.activity_share_recipe, lateinit var logger: Logger
) {
private val viewModel: ShareRecipeViewModel by viewModels() private val viewModel: ShareRecipeViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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") { if (intent.action != Intent.ACTION_SEND || intent.type != "text/plain") {
logger.w { "onCreate: intent.action = ${intent.action}, intent.type = ${intent.type}" } logger.w { "onCreate: intent.action = ${intent.action}, intent.type = ${intent.type}" }
@@ -40,16 +44,17 @@ class ShareRecipeActivity : BaseActivity<ActivityShareRecipeBinding>(
return return
} }
restartAnimationOnEnd()
viewModel.saveResult.observe(this, ::onStateUpdate) viewModel.saveResult.observe(this, ::onStateUpdate)
viewModel.saveRecipeByURL(url) viewModel.saveRecipeByURL(url)
setContent {
AppTheme {
ShareRecipeScreen()
}
}
} }
private fun onStateUpdate(state: OperationUiState<String>) { private fun onStateUpdate(state: OperationUiState<String>) {
binding.progress.isInvisible = !state.isProgress
withAnimatedDrawable {
if (state.isProgress) start() else stop()
}
if (state.isSuccess || state.isFailure) { if (state.isSuccess || state.isFailure) {
showLongToast( showLongToast(
if (state.isSuccess) R.string.activity_share_recipe_success_toast 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> <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_email">Email or username</string>
<string name="fragment_authentication_input_hint_password">Password</string> <string name="fragment_authentication_input_hint_password">Password</string>
<string name="fragment_authentication_input_hint_url">Server URL</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="fragment_recipes_delete_recipe_confirm_dialog_negative_btn">Cancel</string>
<string name="menu_navigation_drawer_change_url">Change URL</string> <string name="menu_navigation_drawer_change_url">Change URL</string>
<string name="search_recipes_hint">Search recipes</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="view_toolbar_navigation_icon_content_description">Open navigation drawer</string>
<string name="fragment_recipes_list_no_recipes">No recipes</string> <string name="fragment_recipes_list_no_recipes">No recipes</string>
<string name="activity_share_recipe_success_toast">Recipe saved successfully.</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_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_positive">Choose how to send</string>
<string name="activity_main_email_logs_confirmation_negative">Cancel</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> </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 com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.add.AddRecipeRepo 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.datasource_test.PORRIDGE_ADD_RECIPE_INFO
import gq.kirmanak.mealient.test.BaseUnitTest import gq.kirmanak.mealient.test.BaseUnitTest
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.flow.first import io.mockk.slot
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeoutOrNull
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
class AddRecipeViewModelTest : BaseUnitTest() { internal class AddRecipeViewModelTest : BaseUnitTest() {
@MockK(relaxUnitFun = true) @MockK(relaxUnitFun = true)
lateinit var addRecipeRepo: AddRecipeRepo lateinit var addRecipeRepo: AddRecipeRepo
@@ -25,50 +25,79 @@ class AddRecipeViewModelTest : BaseUnitTest() {
@Before @Before
override fun setUp() { override fun setUp() {
super.setUp() super.setUp()
coEvery { addRecipeRepo.addRecipeRequestFlow } returns flowOf(EMPTY_ADD_RECIPE_INFO)
subject = AddRecipeViewModel(addRecipeRepo, logger) subject = AddRecipeViewModel(addRecipeRepo, logger)
} }
@Test @Test
fun `when saveRecipe fails then addRecipeResult is false`() = runTest { fun `when saveRecipe fails then addRecipeResult is false`() = runTest {
coEvery { addRecipeRepo.saveRecipe() } throws IllegalStateException() coEvery { addRecipeRepo.saveRecipe() } throws IllegalStateException()
subject.saveRecipe() subject.onEvent(AddRecipeScreenEvent.SaveRecipeClick)
assertThat(subject.addRecipeResult.first()).isFalse() assertThat(subject.screenState.value.snackbarMessage)
.isEqualTo(AddRecipeSnackbarMessage.Error)
} }
@Test @Test
fun `when saveRecipe succeeds then addRecipeResult is true`() = runTest { fun `when saveRecipe succeeds then addRecipeResult is true`() = runTest {
coEvery { addRecipeRepo.saveRecipe() } returns "recipe-slug" coEvery { addRecipeRepo.saveRecipe() } returns "recipe-slug"
subject.saveRecipe() subject.onEvent(AddRecipeScreenEvent.SaveRecipeClick)
assertThat(subject.addRecipeResult.first()).isTrue() assertThat(subject.screenState.value.snackbarMessage)
.isEqualTo(AddRecipeSnackbarMessage.Success)
} }
@Test @Test
fun `when preserve then doesn't update UI`() { fun `when UI is updated then preserves`() {
coEvery { addRecipeRepo.addRecipeRequestFlow } returns flowOf(PORRIDGE_ADD_RECIPE_INFO) subject.onEvent(AddRecipeScreenEvent.RecipeNameInput("Porridge"))
subject.preserve(PORRIDGE_ADD_RECIPE_INFO) val infoSlot = slot<AddRecipeInfo>()
coVerify(inverse = true) { addRecipeRepo.addRecipeRequestFlow } coVerify { addRecipeRepo.preserve(capture(infoSlot)) }
assertThat(infoSlot.captured.name).isEqualTo("Porridge")
} }
@Test @Test
fun `when preservedAddRecipeRequest without loadPreservedRequest then empty`() = runTest { fun `when loadPreservedRequest then updates screenState`() = 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 {
val expected = PORRIDGE_ADD_RECIPE_INFO val expected = PORRIDGE_ADD_RECIPE_INFO
coEvery { addRecipeRepo.addRecipeRequestFlow } returns flowOf(expected) coEvery { addRecipeRepo.addRecipeRequestFlow } returns flowOf(expected)
subject.loadPreservedRequest() subject.doLoadPreservedRequest()
assertThat(subject.preservedAddRecipeRequest.first()).isSameInstanceAs(expected) 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 @Test
fun `when clear then updates preservedAddRecipeRequest`() = runTest { fun `when initialized then name is empty`() {
val expected = PORRIDGE_ADD_RECIPE_INFO assertThat(subject.screenState.value.recipeNameInput).isEmpty()
coEvery { addRecipeRepo.addRecipeRequestFlow } returns flowOf(expected) }
subject.clear()
assertThat(subject.preservedAddRecipeRequest.first()).isSameInstanceAs(expected) @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 package gq.kirmanak.mealient.ui.baseurl
import android.app.Application
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo 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.datasource.TrustedCertificatesStore
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL
import gq.kirmanak.mealient.test.BaseUnitTest import gq.kirmanak.mealient.test.BaseUnitTest
import gq.kirmanak.mealient.ui.OperationUiState
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.coVerifyOrder import io.mockk.coVerifyOrder
import io.mockk.every
import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.MockK
import io.mockk.impl.annotations.RelaxedMockK import io.mockk.impl.annotations.RelaxedMockK
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -25,7 +26,7 @@ import java.io.IOException
import javax.net.ssl.SSLHandshakeException import javax.net.ssl.SSLHandshakeException
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class BaseURLViewModelTest : BaseUnitTest() { internal class BaseURLViewModelTest : BaseUnitTest() {
@MockK(relaxUnitFun = true) @MockK(relaxUnitFun = true)
lateinit var serverInfoRepo: ServerInfoRepo lateinit var serverInfoRepo: ServerInfoRepo
@@ -42,12 +43,19 @@ class BaseURLViewModelTest : BaseUnitTest() {
@RelaxedMockK @RelaxedMockK
lateinit var baseUrlLogRedactor: BaseUrlLogRedactor lateinit var baseUrlLogRedactor: BaseUrlLogRedactor
@MockK(relaxUnitFun = true)
lateinit var application: Application
lateinit var subject: BaseURLViewModel lateinit var subject: BaseURLViewModel
@Before @Before
override fun setUp() { override fun setUp() {
super.setUp() super.setUp()
every { application.getString(any()) } returns ""
every { application.getString(any(), any()) } returns ""
coEvery { serverInfoRepo.getUrl() } returns null
subject = BaseURLViewModel( subject = BaseURLViewModel(
application = application,
serverInfoRepo = serverInfoRepo, serverInfoRepo = serverInfoRepo,
authRepo = authRepo, authRepo = authRepo,
recipeRepo = recipeRepo, recipeRepo = recipeRepo,
@@ -119,7 +127,7 @@ class BaseURLViewModelTest : BaseUnitTest() {
coEvery { serverInfoRepo.tryBaseURL(any()) } returns Result.failure(IOException()) coEvery { serverInfoRepo.tryBaseURL(any()) } returns Result.failure(IOException())
subject.saveBaseUrl(TEST_BASE_URL) subject.saveBaseUrl(TEST_BASE_URL)
advanceUntilIdle() advanceUntilIdle()
assertThat(subject.uiState.value).isInstanceOf(OperationUiState.Failure::class.java) assertThat(subject.screenState.value.errorText).isNotNull()
} }
@Test @Test

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,10 @@ android {
namespace = "gq.kirmanak.mealient.shopping_list" namespace = "gq.kirmanak.mealient.shopping_list"
} }
ksp {
arg("compose-destinations.generateNavGraphs", "false")
}
dependencies { dependencies {
implementation(project(":architecture")) implementation(project(":architecture"))
implementation(project(":logging")) 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.shopping_lists.ui.composables.getErrorMessage
import gq.kirmanak.mealient.ui.AppTheme import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.Dimens 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.components.LazyColumnWithLoadingState
import gq.kirmanak.mealient.ui.preview.ColorSchemePreview 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.data
import gq.kirmanak.mealient.ui.util.error import gq.kirmanak.mealient.ui.util.error
import gq.kirmanak.mealient.ui.util.map import gq.kirmanak.mealient.ui.util.map
@@ -71,7 +73,6 @@ data class ShoppingListNavArgs(
val shoppingListId: String, val shoppingListId: String,
) )
@OptIn(ExperimentalMaterial3Api::class)
@Destination( @Destination(
navArgsDelegate = ShoppingListNavArgs::class, navArgsDelegate = ShoppingListNavArgs::class,
) )
@@ -80,12 +81,50 @@ internal fun ShoppingListScreen(
shoppingListViewModel: ShoppingListViewModel = hiltViewModel(), shoppingListViewModel: ShoppingListViewModel = hiltViewModel(),
) { ) {
val loadingState by shoppingListViewModel.loadingState.collectAsState() 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( val defaultEmptyListError = stringResource(
R.string.shopping_list_screen_empty_list, R.string.shopping_list_screen_empty_list,
loadingState.data?.name.orEmpty() loadingState.data?.name.orEmpty()
) )
LazyColumnWithLoadingState( LazyColumnWithLoadingState(
modifier = modifier,
loadingState = loadingState.map { it.items }, loadingState = loadingState.map { it.items },
emptyListError = loadingState.error?.let { getErrorMessage(it) } ?: defaultEmptyListError, emptyListError = loadingState.error?.let { getErrorMessage(it) } ?: defaultEmptyListError,
retryButtonText = stringResource(id = R.string.shopping_lists_screen_empty_button_refresh), retryButtonText = stringResource(id = R.string.shopping_lists_screen_empty_button_refresh),
@@ -96,11 +135,11 @@ internal fun ShoppingListScreen(
bottom = Dimens.Large * 4, bottom = Dimens.Large * 4,
), ),
verticalArrangement = Arrangement.spacedBy(Dimens.Medium), verticalArrangement = Arrangement.spacedBy(Dimens.Medium),
snackbarText = shoppingListViewModel.errorToShowInSnackbar?.let { getErrorMessage(error = it) }, snackbarText = errorToShowInSnackbar?.let { getErrorMessage(error = it) },
onSnackbarShown = shoppingListViewModel::onSnackbarShown, onSnackbarShown = onSnackbarShown,
onRefresh = shoppingListViewModel::refreshShoppingList, onRefresh = onRefreshRequest,
floatingActionButton = { floatingActionButton = {
FloatingActionButton(onClick = shoppingListViewModel::onAddItemClicked) { FloatingActionButton(onClick = onAddItemClicked) {
Icon( Icon(
imageVector = Icons.Default.Add, imageVector = Icons.Default.Add,
contentDescription = stringResource(id = R.string.shopping_list_screen_add_icon_content_description), contentDescription = stringResource(id = R.string.shopping_list_screen_add_icon_content_description),
@@ -122,31 +161,24 @@ internal fun ShoppingListScreen(
} }
ShoppingListItemEditor( ShoppingListItemEditor(
state = state, state = state,
onEditCancelled = { shoppingListViewModel.onEditCancel(itemState) }, onEditCancelled = { onEditCancel(itemState) },
onEditConfirmed = { onEditConfirmed = { onEditConfirm(itemState, state) },
shoppingListViewModel.onEditConfirm(
itemState,
state
)
}
) )
} else { } else {
ShoppingListItem( ShoppingListItem(
itemState = itemState, itemState = itemState,
showDivider = index == firstCheckedItemIndex && index != 0, showDivider = index == firstCheckedItemIndex && index != 0,
modifier = Modifier.background(MaterialTheme.colorScheme.surface), modifier = Modifier.background(MaterialTheme.colorScheme.surface),
onCheckedChange = { onCheckedChange = { onItemCheckedChange(itemState, it) },
shoppingListViewModel.onItemCheckedChange(itemState, it) onDismissed = { onDeleteItem(itemState) },
}, onEditStart = { onEditStart(itemState) },
onDismissed = { shoppingListViewModel.deleteShoppingListItem(itemState) },
onEditStart = { shoppingListViewModel.onEditStart(itemState) },
) )
} }
} else if (itemState is ShoppingListItemState.NewItem) { } else if (itemState is ShoppingListItemState.NewItem) {
ShoppingListItemEditor( ShoppingListItemEditor(
state = itemState.item, state = itemState.item,
onEditCancelled = { shoppingListViewModel.onAddCancel(itemState) }, onEditCancelled = { onAddCancel(itemState) },
onEditConfirmed = { shoppingListViewModel.onAddConfirm(itemState) } onEditConfirmed = { onAddConfirm(itemState) }
) )
} }
} }
@@ -493,7 +525,7 @@ fun ShoppingListItem(
} }
true true
} }
) ),
) { ) {
val shoppingListItem = itemState.item val shoppingListItem = itemState.item
SwipeToDismiss( 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items 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.Card
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -14,49 +16,55 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph import com.ramcosta.composedestinations.navigation.navigate
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import gq.kirmanak.mealient.datasource.models.GetShoppingListsSummaryResponse import gq.kirmanak.mealient.datasource.models.GetShoppingListsSummaryResponse
import gq.kirmanak.mealient.shopping_list.R import gq.kirmanak.mealient.shopping_list.R
import gq.kirmanak.mealient.shopping_lists.ui.composables.getErrorMessage import gq.kirmanak.mealient.shopping_lists.ui.composables.getErrorMessage
import gq.kirmanak.mealient.shopping_lists.ui.destinations.ShoppingListScreenDestination import gq.kirmanak.mealient.shopping_lists.ui.destinations.ShoppingListScreenDestination
import gq.kirmanak.mealient.ui.AppTheme import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.Dimens 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.components.LazyColumnWithLoadingState
import gq.kirmanak.mealient.ui.preview.ColorSchemePreview import gq.kirmanak.mealient.ui.preview.ColorSchemePreview
import gq.kirmanak.mealient.ui.util.error import gq.kirmanak.mealient.ui.util.error
@RootNavGraph(start = true) @Destination
@Destination(start = true)
@Composable @Composable
fun ShoppingListsScreen( fun ShoppingListsScreen(
navigator: DestinationsNavigator, navController: NavController,
baseScreenState: BaseScreenState,
shoppingListsViewModel: ShoppingListsViewModel = hiltViewModel(), shoppingListsViewModel: ShoppingListsViewModel = hiltViewModel(),
) { ) {
val loadingState by shoppingListsViewModel.loadingState.collectAsState() val loadingState by shoppingListsViewModel.loadingState.collectAsState()
val errorToShowInSnackbar = shoppingListsViewModel.errorToShowInSnackBar val errorToShowInSnackbar = shoppingListsViewModel.errorToShowInSnackBar
LazyColumnWithLoadingState( BaseScreenWithNavigation(
loadingState = loadingState, baseScreenState = baseScreenState,
emptyListError = loadingState.error?.let { getErrorMessage(it) } ) { modifier ->
?: stringResource(R.string.shopping_lists_screen_empty), LazyColumnWithLoadingState(
retryButtonText = stringResource(id = R.string.shopping_lists_screen_empty_button_refresh), modifier = modifier,
snackbarText = errorToShowInSnackbar?.let { getErrorMessage(error = it) }, loadingState = loadingState,
onSnackbarShown = shoppingListsViewModel::onSnackbarShown, emptyListError = loadingState.error?.let { getErrorMessage(it) }
onRefresh = shoppingListsViewModel::refresh ?: stringResource(R.string.shopping_lists_screen_empty),
) { items -> retryButtonText = stringResource(id = R.string.shopping_lists_screen_empty_button_refresh),
items(items) { shoppingList -> snackbarText = errorToShowInSnackbar?.let { getErrorMessage(error = it) },
ShoppingListCard( onSnackbarShown = shoppingListsViewModel::onSnackbarShown,
shoppingList = shoppingList, onRefresh = shoppingListsViewModel::refresh
onItemClick = { clickedEntity -> ) { items ->
val shoppingListId = clickedEntity.id items(items) { shoppingList ->
navigator.navigate(ShoppingListScreenDestination(shoppingListId)) ShoppingListCard(
} shoppingList = shoppingList,
) onItemClick = { clickedEntity ->
val shoppingListId = clickedEntity.id
navController.navigate(ShoppingListScreenDestination(shoppingListId))
}
)
}
} }
} }
} }
@@ -88,7 +96,7 @@ private fun ShoppingListCard(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon( Icon(
painter = painterResource(id = R.drawable.ic_shopping_cart), imageVector = Icons.Default.ShoppingCart,
contentDescription = stringResource(id = R.string.shopping_lists_screen_cart_icon), contentDescription = stringResource(id = R.string.shopping_lists_screen_cart_icon),
modifier = Modifier.height(Dimens.Large), 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-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-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" } 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 = { 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" } 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" } 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" } 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