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:
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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") }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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)}
|
|
||||||
}
|
}
|
||||||
@@ -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") }
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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") }
|
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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?)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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?) {
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
61
app/src/main/java/gq/kirmanak/mealient/ui/NavGraphs.kt
Normal file
61
app/src/main/java/gq/kirmanak/mealient/ui/NavGraphs.kt
Normal 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 }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
312
app/src/main/java/gq/kirmanak/mealient/ui/add/AddRecipeScreen.kt
Normal file
312
app/src/main/java/gq/kirmanak/mealient/ui/add/AddRecipeScreen.kt
Normal 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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package gq.kirmanak.mealient.ui.add
|
||||||
|
|
||||||
|
internal sealed interface AddRecipeSnackbarMessage {
|
||||||
|
|
||||||
|
data object Success : AddRecipeSnackbarMessage
|
||||||
|
|
||||||
|
data object Error : AddRecipeSnackbarMessage
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
37
app/src/main/java/gq/kirmanak/mealient/ui/auth/Extensions.kt
Normal file
37
app/src/main/java/gq/kirmanak/mealient/ui/auth/Extensions.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
114
app/src/main/java/gq/kirmanak/mealient/ui/auth/PasswordInput.kt
Normal file
114
app/src/main/java/gq/kirmanak/mealient/ui/auth/PasswordInput.kt
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package gq.kirmanak.mealient.ui.disclaimer
|
||||||
|
|
||||||
|
internal data class DisclaimerScreenState(
|
||||||
|
val isCountDownOver: Boolean,
|
||||||
|
val countDown: Int,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
@@ -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>
|
|
||||||
@@ -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) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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
Reference in New Issue
Block a user