diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 19fe075..dc5b545 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -77,6 +77,10 @@ android { } } +ksp { + arg("compose-destinations.generateNavGraphs", "false") +} + dependencies { implementation(project(":architecture")) @@ -112,6 +116,8 @@ dependencies { implementation(libs.androidx.shareTarget) + implementation(libs.androidx.compose.materialIconsExtended) + implementation(libs.google.dagger.hiltAndroid) kapt(libs.google.dagger.hiltCompiler) kaptTest(libs.google.dagger.hiltAndroidCompiler) @@ -132,6 +138,10 @@ dependencies { implementation(libs.coil) implementation(libs.coil.compose) + implementation(libs.androidx.compose.animation) + + implementation(libs.androidx.hilt.navigationCompose) + testImplementation(libs.junit) implementation(libs.jetbrains.kotlinx.coroutinesAndroid) diff --git a/app/src/androidTest/kotlin/gq/kirmanak/mealient/FirstSetUpTest.kt b/app/src/androidTest/kotlin/gq/kirmanak/mealient/FirstSetUpTest.kt index eae96fe..1821d16 100644 --- a/app/src/androidTest/kotlin/gq/kirmanak/mealient/FirstSetUpTest.kt +++ b/app/src/androidTest/kotlin/gq/kirmanak/mealient/FirstSetUpTest.kt @@ -1,9 +1,11 @@ package gq.kirmanak.mealient import dagger.hilt.android.testing.HiltAndroidTest +import gq.kirmanak.mealient.screen.AuthenticationScreen import gq.kirmanak.mealient.screen.BaseUrlScreen import gq.kirmanak.mealient.screen.DisclaimerScreen import gq.kirmanak.mealient.screen.RecipesListScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.kakao.common.utilities.getResourceString import org.junit.Before import org.junit.Test @@ -13,63 +15,137 @@ class FirstSetUpTest : BaseTestCase() { @Before fun dispatchUrls() { - mockWebServer.dispatch { url, _ -> - if (url == "/api/app/about") versionV1Response else notFoundResponse + mockWebServer.dispatch { request -> + when (request.path) { + "/api/app/about" -> versionV1Response + + "/api/auth/token" -> { + if (request.body == expectedLoginRequest) { + loginTokenResponse + } else { + notFoundResponse + } + } + + "/api/users/api-tokens" -> { + if (request.authorization == expectedApiTokenAuthorizationHeader) { + apiTokenResponse + } else { + notFoundResponse + } + } + + "/api/recipes?page=1&perPage=150" -> { + if (request.authorization == expectedAuthorizationHeader || + request.authorization == expectedApiTokenAuthorizationHeader + ) { + recipeSummariesResponse + } else { + notFoundResponse + } + } + + else -> notFoundResponse + } } } @Test fun test() = run { - step("Ensure button is disabled") { - DisclaimerScreen { + step("Disclaimer screen with disabled button") { + onComposeScreen(mainActivityRule) { okayButton { - isVisible() - isDisabled() - hasAnyText() + assertIsNotEnabled() + } + + okayButtonText { + assertTextContains(getResourceString(R.string.fragment_disclaimer_button_okay)) } disclaimerText { - isVisible() - hasText(R.string.fragment_disclaimer_main_text) + assertTextEquals(getResourceString(R.string.fragment_disclaimer_main_text)) } } } step("Close disclaimer screen") { - DisclaimerScreen { + onComposeScreen(mainActivityRule) { + okayButtonText { + assertTextEquals(getResourceString(R.string.fragment_disclaimer_button_okay)) + } + okayButton { - isVisible() - isEnabled() - hasText(R.string.fragment_disclaimer_button_okay) - click() + assertIsEnabled() + performClick() } } } step("Enter mock server address and click proceed") { - BaseUrlScreen { + onComposeScreen(mainActivityRule) { progressBar { - isGone() + assertDoesNotExist() } urlInput { - isVisible() - edit.replaceText(mockWebServer.url("/").toString()) - hasHint(R.string.fragment_authentication_input_hint_url) + performTextInput(mockWebServer.url("/").toString()) + } + urlInputLabel { + assertTextEquals(getResourceString(R.string.fragment_authentication_input_hint_url)) + } + proceedButtonText { + assertTextEquals(getResourceString(R.string.fragment_base_url_save)) } proceedButton { - isVisible() - isEnabled() - hasText(R.string.fragment_base_url_save) - click() + assertIsEnabled() + performClick() } } } - step("Check that empty list of recipes is shown") { - RecipesListScreen(mainActivityRule).apply { - errorText { + step("Check that authentication is shown") { + onComposeScreen(mainActivityRule) { + emailInput { + assertIsDisplayed() + } + + passwordInput { + assertIsDisplayed() + } + + loginButton { + assertIsDisplayed() + } + } + } + + step("Enter credentials and click proceed") { + onComposeScreen(mainActivityRule) { + emailInput { + performTextInput("test@test.test") + } + + passwordInput { + performTextInput("password") + } + + loginButton { + performClick() + } + } + } + + step("Check that empty recipes list is shown") { + onComposeScreen(mainActivityRule) { + emptyListErrorText { + assertTextEquals(getResourceString(R.string.fragment_recipes_list_no_recipes)) + } + + openDrawerButton { + assertIsDisplayed() + } + + searchRecipesField { assertIsDisplayed() - assertTextEquals(getResourceString(R.string.fragment_recipes_load_failure_toast_no_reason)) } } } diff --git a/app/src/androidTest/kotlin/gq/kirmanak/mealient/MockResponse.kt b/app/src/androidTest/kotlin/gq/kirmanak/mealient/MockResponse.kt new file mode 100644 index 0000000..2db4c76 --- /dev/null +++ b/app/src/androidTest/kotlin/gq/kirmanak/mealient/MockResponse.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/gq/kirmanak/mealient/response.VersionResponses.kt b/app/src/androidTest/kotlin/gq/kirmanak/mealient/response.VersionResponses.kt deleted file mode 100644 index 16d6e2e..0000000 --- a/app/src/androidTest/kotlin/gq/kirmanak/mealient/response.VersionResponses.kt +++ /dev/null @@ -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) - } - } -} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/AuthenticationScreen.kt b/app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/AuthenticationScreen.kt new file mode 100644 index 0000000..430e5a7 --- /dev/null +++ b/app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/AuthenticationScreen.kt @@ -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( + semanticsProvider = semanticsProvider, + viewBuilderAction = { hasTestTag("authentication-screen") }, +) { + + val emailInput = child { hasTestTag("email-input") } + + val passwordInput = child { hasTestTag("password-input") } + + val loginButton = child { hasTestTag("login-button") } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/BaseUrlScreen.kt b/app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/BaseUrlScreen.kt index 00f549f..b3e0f8b 100644 --- a/app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/BaseUrlScreen.kt +++ b/app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/BaseUrlScreen.kt @@ -1,19 +1,25 @@ package gq.kirmanak.mealient.screen -import com.kaspersky.kaspresso.screens.KScreen -import gq.kirmanak.mealient.R -import gq.kirmanak.mealient.ui.baseurl.BaseURLFragment -import io.github.kakaocup.kakao.edit.KTextInputLayout -import io.github.kakaocup.kakao.progress.KProgressBar -import io.github.kakaocup.kakao.text.KButton +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.KNode -object BaseUrlScreen : KScreen() { - override val layoutId = R.layout.fragment_base_url - override val viewClass = BaseURLFragment::class.java +class BaseUrlScreen( + semanticsProvider: SemanticsNodeInteractionsProvider, +) : ComposeScreen( + semanticsProvider = semanticsProvider, + viewBuilderAction = { hasTestTag("base-url-screen") }, +) { - val urlInput = KTextInputLayout { withId(R.id.url_input_layout) } + val urlInput = child { hasTestTag("url-input-field") } - val proceedButton = KButton { withId(R.id.button) } + val urlInputLabel = unmergedChild { hasTestTag("url-input-label") } + + val proceedButton = child { hasTestTag("proceed-button") } + + val proceedButtonText = + unmergedChild { hasTestTag("proceed-button-text") } + + val progressBar = unmergedChild { hasTestTag("progress-indicator") } - val progressBar = KProgressBar { withId(R.id.progress)} } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/DisclaimerScreen.kt b/app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/DisclaimerScreen.kt index 5dafb30..71ae6ad 100644 --- a/app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/DisclaimerScreen.kt +++ b/app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/DisclaimerScreen.kt @@ -1,16 +1,19 @@ package gq.kirmanak.mealient.screen -import com.kaspersky.kaspresso.screens.KScreen -import gq.kirmanak.mealient.R -import gq.kirmanak.mealient.ui.disclaimer.DisclaimerFragment -import io.github.kakaocup.kakao.text.KButton -import io.github.kakaocup.kakao.text.KTextView +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.KNode -object DisclaimerScreen : KScreen() { - override val layoutId = R.layout.fragment_disclaimer - override val viewClass = DisclaimerFragment::class.java +internal class DisclaimerScreen( + semanticsProvider: SemanticsNodeInteractionsProvider, +) : ComposeScreen( + semanticsProvider = semanticsProvider, + viewBuilderAction = { hasTestTag("disclaimer-screen") }, +) { - val okayButton = KButton { withId(R.id.okay) } + val okayButton = child { hasTestTag("okay-button") } - val disclaimerText = KTextView { withId(R.id.main_text) } + val okayButtonText = unmergedChild { hasTestTag("okay-button-text") } + + val disclaimerText = child { hasTestTag("disclaimer-text") } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/Extensions.kt b/app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/Extensions.kt new file mode 100644 index 0000000..1e5f8ab --- /dev/null +++ b/app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/Extensions.kt @@ -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 > BaseNode.unmergedChild( + function: ViewBuilder.() -> Unit, +): N = child { + useUnmergedTree = true + function() +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/RecipesListScreen.kt b/app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/RecipesListScreen.kt index 285ae1c..8767632 100644 --- a/app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/RecipesListScreen.kt +++ b/app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/RecipesListScreen.kt @@ -1,20 +1,18 @@ package gq.kirmanak.mealient.screen -import androidx.activity.ComponentActivity -import androidx.compose.ui.test.junit4.AndroidComposeTestRule -import androidx.compose.ui.test.onRoot -import androidx.compose.ui.test.printToLog +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.KNode -import org.junit.rules.TestRule -class RecipesListScreen( - semanticsProvider: AndroidComposeTestRule, -) : ComposeScreen>(semanticsProvider) { +internal class RecipesListScreen( + semanticsProvider: SemanticsNodeInteractionsProvider, +) : ComposeScreen(semanticsProvider) { - init { - semanticsProvider.onRoot(useUnmergedTree = true).printToLog("RecipesListScreen") + val openDrawerButton = child { hasTestTag("open-drawer-button") } + + val searchRecipesField = child { hasTestTag("search-recipes-field") } + + val emptyListErrorText = unmergedChild { + hasTestTag("empty-list-error-text") } - - val errorText: KNode = child { hasTestTag("empty-list-error-text") } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/baseurl/ServerInfoRepo.kt b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/ServerInfoRepo.kt index 0928db1..624ae6e 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/baseurl/ServerInfoRepo.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/ServerInfoRepo.kt @@ -1,7 +1,11 @@ package gq.kirmanak.mealient.data.baseurl +import kotlinx.coroutines.flow.Flow + interface ServerInfoRepo { + val baseUrlFlow: Flow + suspend fun getUrl(): String? suspend fun tryBaseURL(baseURL: String): Result diff --git a/app/src/main/java/gq/kirmanak/mealient/data/baseurl/ServerInfoRepoImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/ServerInfoRepoImpl.kt index 70f72bb..0af8d62 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/baseurl/ServerInfoRepoImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/ServerInfoRepoImpl.kt @@ -2,6 +2,7 @@ package gq.kirmanak.mealient.data.baseurl import gq.kirmanak.mealient.datasource.ServerUrlProvider import gq.kirmanak.mealient.logging.Logger +import kotlinx.coroutines.flow.Flow import javax.inject.Inject class ServerInfoRepoImpl @Inject constructor( @@ -10,6 +11,9 @@ class ServerInfoRepoImpl @Inject constructor( private val logger: Logger, ) : ServerInfoRepo, ServerUrlProvider { + override val baseUrlFlow: Flow + get() = serverInfoStorage.baseUrlFlow + override suspend fun getUrl(): String? { val result = serverInfoStorage.getBaseURL() logger.v { "getUrl() returned: $result" } diff --git a/app/src/main/java/gq/kirmanak/mealient/data/baseurl/ServerInfoStorage.kt b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/ServerInfoStorage.kt index ba490a5..c8d2e2f 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/baseurl/ServerInfoStorage.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/ServerInfoStorage.kt @@ -1,7 +1,11 @@ package gq.kirmanak.mealient.data.baseurl +import kotlinx.coroutines.flow.Flow + interface ServerInfoStorage { + val baseUrlFlow: Flow + suspend fun getBaseURL(): String? suspend fun storeBaseURL(baseURL: String?) diff --git a/app/src/main/java/gq/kirmanak/mealient/data/baseurl/impl/BaseUrlLogRedactor.kt b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/impl/BaseUrlLogRedactor.kt index 02a623b..88532c2 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/baseurl/impl/BaseUrlLogRedactor.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/impl/BaseUrlLogRedactor.kt @@ -29,9 +29,10 @@ class BaseUrlLogRedactor @Inject constructor( private fun setInitialBaseUrl() { val scope = CoroutineScope(dispatchers.default + SupervisorJob()) scope.launch { + val baseUrl = preferencesStorage.getValue(preferencesStorage.baseUrlKey) hostState.compareAndSet( expect = null, - update = preferencesStorage.getValue(preferencesStorage.baseUrlKey) + update = baseUrl?.extractHost() ) } } @@ -40,7 +41,6 @@ class BaseUrlLogRedactor @Inject constructor( hostState.value = baseUrl.extractHost() } - override fun redact(message: String): String { val host = hostState.value return when { diff --git a/app/src/main/java/gq/kirmanak/mealient/data/baseurl/impl/ServerInfoStorageImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/impl/ServerInfoStorageImpl.kt index 617611c..84c32eb 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/baseurl/impl/ServerInfoStorageImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/impl/ServerInfoStorageImpl.kt @@ -3,6 +3,7 @@ package gq.kirmanak.mealient.data.baseurl.impl import androidx.datastore.preferences.core.Preferences import gq.kirmanak.mealient.data.baseurl.ServerInfoStorage import gq.kirmanak.mealient.data.storage.PreferencesStorage +import kotlinx.coroutines.flow.Flow import javax.inject.Inject class ServerInfoStorageImpl @Inject constructor( @@ -12,6 +13,9 @@ class ServerInfoStorageImpl @Inject constructor( private val baseUrlKey: Preferences.Key get() = preferencesStorage.baseUrlKey + override val baseUrlFlow: Flow + get() = preferencesStorage.valueUpdates(baseUrlKey) + override suspend fun getBaseURL(): String? = getValue(baseUrlKey) override suspend fun storeBaseURL(baseURL: String?) { diff --git a/app/src/main/java/gq/kirmanak/mealient/extensions/FragmentExtensions.kt b/app/src/main/java/gq/kirmanak/mealient/extensions/FragmentExtensions.kt deleted file mode 100644 index 0ab4993..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/extensions/FragmentExtensions.kt +++ /dev/null @@ -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 Fragment.collectWhenViewResumed(flow: Flow, collector: FlowCollector) { - viewLifecycleOwner.collectWhenResumed(flow, collector) -} - -fun Fragment.showLongToast(@StringRes text: Int) = context?.showLongToast(text) != null - -fun Fragment.showLongToast(text: String) = context?.showLongToast(text) != null - diff --git a/app/src/main/java/gq/kirmanak/mealient/extensions/ViewExtensions.kt b/app/src/main/java/gq/kirmanak/mealient/extensions/ViewExtensions.kt index c70c5eb..dbcb784 100644 --- a/app/src/main/java/gq/kirmanak/mealient/extensions/ViewExtensions.kt +++ b/app/src/main/java/gq/kirmanak/mealient/extensions/ViewExtensions.kt @@ -4,59 +4,16 @@ import android.content.Context import android.content.SharedPreferences import android.content.res.Configuration.UI_MODE_NIGHT_MASK import android.content.res.Configuration.UI_MODE_NIGHT_YES -import android.content.res.Resources import android.os.Build -import android.view.View -import android.view.inputmethod.InputMethodManager -import android.widget.EditText -import android.widget.TextView import android.widget.Toast import androidx.annotation.StringRes -import androidx.core.content.getSystemService -import androidx.core.widget.doAfterTextChanged -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LiveData -import androidx.lifecycle.Observer -import androidx.lifecycle.asLiveData -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import androidx.viewbinding.ViewBinding -import com.google.android.material.textfield.TextInputLayout import gq.kirmanak.mealient.logging.Logger import kotlinx.coroutines.channels.ChannelResult import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.onClosed import kotlinx.coroutines.channels.onFailure import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.launch - -fun SwipeRefreshLayout.refreshRequestFlow(logger: Logger): Flow = 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 = 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 ChannelResult.logErrors(methodName: String, logger: Logger): ChannelResult { onFailure { logger.e(it) { "$methodName: can't send event" } } @@ -64,30 +21,6 @@ fun ChannelResult.logErrors(methodName: String, logger: Logger): ChannelR return this } -fun EditText.checkIfInputIsEmpty( - inputLayout: TextInputLayout, - lifecycleOwner: LifecycleOwner, - @StringRes stringId: Int, - trim: Boolean = true, - logger: Logger, -): String? { - val input = if (trim) text?.trim() else text - val text = input?.toString().orEmpty() - return text.ifEmpty { - inputLayout.error = resources.getString(stringId) - val textChangesLiveData = textChangesLiveData(logger) - textChangesLiveData.observe(lifecycleOwner, object : Observer { - override fun onChanged(value: CharSequence?) { - if (value.isNullOrBlank().not()) { - inputLayout.error = null - textChangesLiveData.removeObserver(this) - } - } - }) - null - } -} - fun SharedPreferences.prefsChangeFlow( logger: Logger, valueReader: SharedPreferences.() -> T, @@ -99,16 +32,6 @@ fun SharedPreferences.prefsChangeFlow( awaitClose { unregisterOnSharedPreferenceChangeListener(listener) } } -fun LiveData.observeOnce(lifecycleOwner: LifecycleOwner, observer: Observer) { - observe(lifecycleOwner, object : Observer { - override fun onChanged(value: T) { - removeObserver(this) - observer.onChanged(value) - } - }) -} - - fun Context.showLongToast(text: String) = showToast(text, Toast.LENGTH_LONG) fun Context.showLongToast(@StringRes text: Int) = showLongToast(getString(text)) @@ -117,23 +40,8 @@ private fun Context.showToast(text: String, length: Int) { Toast.makeText(this, text, length).show() } -fun View.hideKeyboard() { - val imm = context.getSystemService() - imm?.hideSoftInputFromWindow(windowToken, 0) -} - fun Context.isDarkThemeOn(): Boolean { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) resources.configuration.isNightModeActive else resources.configuration.uiMode and UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES } -fun LifecycleOwner.collectWhenResumed(flow: Flow, collector: FlowCollector) { - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.RESUMED) { - flow.collect(collector) - } - } -} - -val T.resources: Resources - get() = root.resources \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/NavGraphs.kt b/app/src/main/java/gq/kirmanak/mealient/ui/NavGraphs.kt new file mode 100644 index 0000000..7433421 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/NavGraphs.kt @@ -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>, + override val nestedNavGraphs: List = emptyList() +) : NavGraphSpec { + + override val destinationsByRoute: Map> = + destinations.associateBy { it.route } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/activity/DrawerContent.kt b/app/src/main/java/gq/kirmanak/mealient/ui/activity/DrawerContent.kt new file mode 100644 index 0000000..c39b47b --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/activity/DrawerContent.kt @@ -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 { + 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 +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivity.kt b/app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivity.kt index 00c7a0a..8263b29 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivity.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivity.kt @@ -1,185 +1,42 @@ package gq.kirmanak.mealient.ui.activity -import android.content.Intent -import android.net.Uri import android.os.Bundle -import android.view.MenuItem +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent import androidx.activity.viewModels -import androidx.core.content.FileProvider import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import androidx.core.view.isVisible -import androidx.core.view.iterator -import androidx.drawerlayout.widget.DrawerLayout -import androidx.navigation.NavController -import androidx.navigation.NavDirections -import androidx.navigation.fragment.NavHostFragment -import com.google.android.material.dialog.MaterialAlertDialogBuilder +import androidx.core.view.WindowInsetsControllerCompat import dagger.hilt.android.AndroidEntryPoint -import gq.kirmanak.mealient.NavGraphDirections.Companion.actionGlobalAddRecipeFragment -import gq.kirmanak.mealient.NavGraphDirections.Companion.actionGlobalAuthenticationFragment -import gq.kirmanak.mealient.NavGraphDirections.Companion.actionGlobalBaseURLFragment -import gq.kirmanak.mealient.NavGraphDirections.Companion.actionGlobalRecipesListFragment -import gq.kirmanak.mealient.NavGraphDirections.Companion.actionGlobalShoppingListsFragment -import gq.kirmanak.mealient.R -import gq.kirmanak.mealient.databinding.MainActivityBinding -import gq.kirmanak.mealient.extensions.collectWhenResumed -import gq.kirmanak.mealient.extensions.observeOnce -import gq.kirmanak.mealient.logging.getLogFile -import gq.kirmanak.mealient.ui.ActivityUiState -import gq.kirmanak.mealient.ui.BaseActivity -import gq.kirmanak.mealient.ui.CheckableMenuItem - -private const val EMAIL_FOR_LOGS = "mealient@gmail.com" +import gq.kirmanak.mealient.extensions.isDarkThemeOn +import gq.kirmanak.mealient.logging.Logger +import gq.kirmanak.mealient.ui.AppTheme +import javax.inject.Inject @AndroidEntryPoint -class MainActivity : BaseActivity( - binder = MainActivityBinding::bind, - containerId = R.id.drawer, - layoutRes = R.layout.main_activity, -) { +class MainActivity : ComponentActivity() { + + @Inject + lateinit var logger: Logger private val viewModel by viewModels() - private val navController: NavController - get() = binding.navHost.getFragment().navController override fun onCreate(savedInstanceState: Bundle?) { val splashScreen = installSplashScreen() super.onCreate(savedInstanceState) - splashScreen.setKeepOnScreenCondition { viewModel.startDestination.value == null } - setupUi() - configureNavGraph() - } - - private fun configureNavGraph() { - logger.v { "configureNavGraph() called" } - viewModel.startDestination.observeOnce(this) { - logger.d { "configureNavGraph: received destination" } - val controller = navController - val graph = controller.navInflater.inflate(R.navigation.nav_graph) - graph.setStartDestination(it.startDestinationId) - controller.setGraph(graph, it.startDestinationArgs) + logger.v { "onCreate() called with: savedInstanceState = $savedInstanceState" } + with(WindowInsetsControllerCompat(window, window.decorView)) { + val isAppearanceLightBars = !isDarkThemeOn() + isAppearanceLightNavigationBars = isAppearanceLightBars + isAppearanceLightStatusBars = isAppearanceLightBars } - } - - private fun setupUi() { - binding.toolbar.setNavigationOnClickListener { - binding.toolbar.clearSearchFocus() - binding.drawer.open() + splashScreen.setKeepOnScreenCondition { + viewModel.appState.value.forcedRoute == ForcedDestination.Undefined } - binding.toolbar.onSearchQueryChanged { query -> - viewModel.onSearchQuery(query.trim().takeUnless { it.isEmpty() }) - } - binding.navigationView.setNavigationItemSelectedListener(::onNavigationItemSelected) - collectWhenResumed(viewModel.uiState, ::onUiStateChange) - collectWhenResumed(viewModel.clearSearchViewFocus) { - logger.d { "clearSearchViewFocus(): received event" } - binding.toolbar.clearSearchFocus() - } - } - - private fun onNavigationItemSelected(menuItem: MenuItem): Boolean { - logger.v { "onNavigationItemSelected() called with: menuItem = $menuItem" } - if (menuItem.isChecked) { - logger.d { "Not navigating because it is the current destination" } - binding.drawer.close() - return true - } - val directions = when (menuItem.itemId) { - R.id.add_recipe -> actionGlobalAddRecipeFragment() - R.id.recipes_list -> actionGlobalRecipesListFragment() - R.id.shopping_lists -> actionGlobalShoppingListsFragment() - R.id.change_url -> actionGlobalBaseURLFragment(false) - R.id.login -> actionGlobalAuthenticationFragment() - R.id.logout -> { - viewModel.logout() - return true + setContent { + AppTheme { + MealientApp(viewModel) } - - R.id.email_logs -> { - emailLogs() - return true - } - - else -> throw IllegalArgumentException("Unknown menu item id: ${menuItem.itemId}") - } - menuItem.isChecked = true - navigateTo(directions) - return true - } - - private fun emailLogs() { - MaterialAlertDialogBuilder(this) - .setMessage(R.string.activity_main_email_logs_confirmation_message) - .setTitle(R.string.activity_main_email_logs_confirmation_title) - .setPositiveButton(R.string.activity_main_email_logs_confirmation_positive) { _, _ -> doEmailLogs() } - .setNegativeButton(R.string.activity_main_email_logs_confirmation_negative, null) - .show() - } - - private fun doEmailLogs() { - val logFileUri = try { - FileProvider.getUriForFile(this, "$packageName.provider", getLogFile()) - } catch (e: Exception) { - return - } - val emailIntent = buildIntent(logFileUri) - val chooserIntent = Intent.createChooser(emailIntent, null) - startActivity(chooserIntent) - } - - private fun buildIntent(logFileUri: Uri?): Intent { - val emailIntent = Intent(Intent.ACTION_SEND) - val to = arrayOf(EMAIL_FOR_LOGS) - emailIntent.setType("text/plain") - emailIntent.putExtra(Intent.EXTRA_EMAIL, to) - emailIntent.putExtra(Intent.EXTRA_STREAM, logFileUri) - emailIntent.putExtra( - Intent.EXTRA_SUBJECT, - getString(R.string.activity_main_email_logs_subject) - ) - return emailIntent - } - - private fun onUiStateChange(uiState: ActivityUiState) { - logger.v { "onUiStateChange() called with: uiState = $uiState" } - val checkedMenuItem = when (uiState.checkedMenuItem) { - CheckableMenuItem.ShoppingLists -> R.id.shopping_lists - CheckableMenuItem.RecipesList -> R.id.recipes_list - CheckableMenuItem.AddRecipe -> R.id.add_recipe - CheckableMenuItem.ChangeUrl -> R.id.change_url - CheckableMenuItem.Login -> R.id.login - null -> null - } - for (menuItem in binding.navigationView.menu.iterator()) { - val itemId = menuItem.itemId - when (itemId) { - R.id.logout -> menuItem.isVisible = uiState.canShowLogout - R.id.login -> menuItem.isVisible = uiState.canShowLogin - } - menuItem.isChecked = itemId == checkedMenuItem - } - - binding.toolbar.isVisible = uiState.navigationVisible - binding.root.setDrawerLockMode( - if (uiState.navigationVisible) { - DrawerLayout.LOCK_MODE_UNLOCKED - } else { - DrawerLayout.LOCK_MODE_LOCKED_CLOSED - } - ) - - binding.toolbar.isSearchVisible = uiState.searchVisible - - if (uiState.searchVisible) { - binding.toolbar.setBackgroundResource(R.drawable.bg_toolbar) - } else { - binding.toolbar.background = null } } - private fun navigateTo(directions: NavDirections) { - logger.v { "navigateTo() called with: directions = $directions" } - binding.drawer.close() - navController.navigate(directions) - } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivityViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivityViewModel.kt index 6034326..b7012a6 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivityViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivityViewModel.kt @@ -1,7 +1,9 @@ package gq.kirmanak.mealient.ui.activity -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData +import android.app.Application +import android.content.Intent +import androidx.annotation.StringRes +import androidx.core.content.FileProvider import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -9,74 +11,202 @@ import gq.kirmanak.mealient.R import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage -import gq.kirmanak.mealient.data.recipes.RecipeRepo import gq.kirmanak.mealient.logging.Logger -import gq.kirmanak.mealient.ui.ActivityUiState -import gq.kirmanak.mealient.ui.ActivityUiStateController -import gq.kirmanak.mealient.ui.baseurl.BaseURLFragmentArgs -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow +import gq.kirmanak.mealient.logging.getLogFile +import gq.kirmanak.mealient.ui.destinations.AuthenticationScreenDestination +import gq.kirmanak.mealient.ui.destinations.BaseURLScreenDestination +import gq.kirmanak.mealient.ui.destinations.DirectionDestination +import gq.kirmanak.mealient.ui.destinations.DisclaimerScreenDestination +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class MainActivityViewModel @Inject constructor( +internal class MainActivityViewModel @Inject constructor( + private val application: Application, private val authRepo: AuthRepo, private val logger: Logger, private val disclaimerStorage: DisclaimerStorage, private val serverInfoRepo: ServerInfoRepo, - private val recipeRepo: RecipeRepo, - private val activityUiStateController: ActivityUiStateController, ) : ViewModel() { - val uiState: StateFlow = activityUiStateController.getUiStateFlow() - - private val _startDestination = MutableLiveData() - val startDestination: LiveData = _startDestination - - private val _clearSearchViewFocusChannel = Channel() - val clearSearchViewFocus: Flow = _clearSearchViewFocusChannel.receiveAsFlow() + private val _appState = MutableStateFlow(MealientAppState()) + val appState: StateFlow get() = _appState.asStateFlow() init { - authRepo.isAuthorizedFlow - .onEach { isAuthorized -> updateUiState { it.copy(isAuthorized = isAuthorized) } } - .launchIn(viewModelScope) + checkForcedDestination() + } + + private fun checkForcedDestination() { + logger.v { "checkForcedDestination() called" } + val baseUrlSetState = serverInfoRepo.baseUrlFlow.map { it != null } + val tokenExistsState = authRepo.isAuthorizedFlow + val disclaimerAcceptedState = disclaimerStorage.isDisclaimerAcceptedFlow viewModelScope.launch { - _startDestination.value = when { - !disclaimerStorage.isDisclaimerAccepted() -> { - StartDestinationInfo(R.id.disclaimerFragment) - } - serverInfoRepo.getUrl() == null -> { - StartDestinationInfo(R.id.baseURLFragment, BaseURLFragmentArgs(true).toBundle()) - } - else -> { - StartDestinationInfo(R.id.recipesListFragment) + combine( + baseUrlSetState, + tokenExistsState, + disclaimerAcceptedState, + ) { baseUrlSet, tokenExists, disclaimerAccepted -> + logger.d { "baseUrlSet = $baseUrlSet, tokenExists = $tokenExists, disclaimerAccepted = $disclaimerAccepted" } + when { + !disclaimerAccepted -> ForcedDestination.Destination(DisclaimerScreenDestination) + !baseUrlSet -> ForcedDestination.Destination(BaseURLScreenDestination) + !tokenExists -> ForcedDestination.Destination(AuthenticationScreenDestination) + else -> ForcedDestination.None } + }.collect { destination -> + logger.v { "destination = $destination" } + _appState.update { it.copy(forcedRoute = destination) } } } } - fun updateUiState(updater: (ActivityUiState) -> ActivityUiState) { - activityUiStateController.updateUiState(updater) + fun onEvent(event: AppEvent) { + logger.v { "onEvent() called with: event = $event" } + when (event) { + is AppEvent.Logout -> { + _appState.update { + it.copy(dialogState = logoutConfirmationDialog()) + } + } + + is AppEvent.LogoutConfirm -> { + _appState.update { it.copy(dialogState = null) } + viewModelScope.launch { authRepo.logout() } + } + + is AppEvent.EmailLogs -> { + _appState.update { + it.copy(dialogState = emailConfirmationDialog()) + } + } + + is AppEvent.EmailLogsConfirm -> { + _appState.update { + it.copy( + dialogState = null, + intentToLaunch = logEmailIntent(), + ) + } + } + + is AppEvent.DismissDialog -> { + _appState.update { it.copy(dialogState = null) } + } + + is AppEvent.LaunchedIntent -> { + _appState.update { it.copy(intentToLaunch = null) } + } + } } - fun logout() { - logger.v { "logout() called" } - viewModelScope.launch { authRepo.logout() } + private fun logEmailIntent(): Intent? { + val logFileUri = try { + FileProvider.getUriForFile( + /* context = */ application, + /* authority = */ "${application.packageName}.provider", + /* file = */ application.getLogFile(), + ) + } catch (e: IllegalArgumentException) { + logger.e(e) { "Failed to get log file URI" } + return null + } + + if (logFileUri == null) { + logger.e { "logFileUri is null" } + return null + } + + logger.v { "logFileUri = $logFileUri" } + + val emailIntent = Intent(Intent.ACTION_SEND).apply { + val subject = application.getString(R.string.activity_main_email_logs_subject) + val to = arrayOf(EMAIL_FOR_LOGS) + type = "text/plain" + putExtra(Intent.EXTRA_EMAIL, to) + putExtra(Intent.EXTRA_STREAM, logFileUri) + putExtra(Intent.EXTRA_SUBJECT, subject) + } + + return Intent.createChooser(emailIntent, null) } - fun onSearchQuery(query: String?) { - logger.v { "onSearchQuery() called with: query = $query" } - recipeRepo.updateNameQuery(query) - } + private fun logoutConfirmationDialog() = DialogState( + title = R.string.activity_main_logout_confirmation_title, + message = R.string.activity_main_logout_confirmation_message, + positiveButton = R.string.activity_main_logout_confirmation_positive, + negativeButton = R.string.activity_main_logout_confirmation_negative, + onPositiveClick = AppEvent.LogoutConfirm, + ) - fun clearSearchViewFocus() { - logger.v { "clearSearchViewFocus() called" } - _clearSearchViewFocusChannel.trySend(Unit) + private fun emailConfirmationDialog() = DialogState( + title = R.string.activity_main_email_logs_confirmation_title, + message = R.string.activity_main_email_logs_confirmation_message, + positiveButton = R.string.activity_main_email_logs_confirmation_positive, + negativeButton = R.string.activity_main_email_logs_confirmation_negative, + onPositiveClick = AppEvent.EmailLogsConfirm, + ) + + companion object { + private const val EMAIL_FOR_LOGS = "mealient@gmail.com" } +} + +internal data class MealientAppState( + val forcedRoute: ForcedDestination = ForcedDestination.Undefined, + val searchQuery: String = "", + val dialogState: DialogState? = null, + val intentToLaunch: Intent? = null, +) + +internal data class DialogState( + @StringRes val title: Int, + @StringRes val message: Int, + @StringRes val positiveButton: Int, + @StringRes val negativeButton: Int, + val onPositiveClick: AppEvent, + val onDismiss: AppEvent = AppEvent.DismissDialog, + val onNegativeClick: AppEvent = onDismiss, +) + +internal sealed interface ForcedDestination { + + /** + * Force navigation is required + */ + data class Destination( + val route: DirectionDestination, + ) : ForcedDestination + + /** + * The conditions were checked, no force navigation required + */ + data object None : ForcedDestination + + /** + * The conditions were not checked yet + */ + data object Undefined : ForcedDestination +} + +internal sealed interface AppEvent { + + data object Logout : AppEvent + + data object EmailLogs : AppEvent + + data object DismissDialog : AppEvent + + data object LogoutConfirm : AppEvent + + data object EmailLogsConfirm : AppEvent + + data object LaunchedIntent : AppEvent } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/activity/MealientApp.kt b/app/src/main/java/gq/kirmanak/mealient/ui/activity/MealientApp.kt new file mode 100644 index 0000000..65c8a44 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/activity/MealientApp.kt @@ -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) + } + ) +} + diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/activity/StartDestinationInfo.kt b/app/src/main/java/gq/kirmanak/mealient/ui/activity/StartDestinationInfo.kt deleted file mode 100644 index 36eec48..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/activity/StartDestinationInfo.kt +++ /dev/null @@ -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, -) diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/activity/ToolbarView.kt b/app/src/main/java/gq/kirmanak/mealient/ui/activity/ToolbarView.kt deleted file mode 100644 index 71215e2..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/activity/ToolbarView.kt +++ /dev/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() - } -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/add/AddRecipeFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/add/AddRecipeFragment.kt deleted file mode 100644 index 7861462..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/add/AddRecipeFragment.kt +++ /dev/null @@ -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() - private val activityViewModel by activityViewModels() - - @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 = - 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.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(id) ?: continue - removeView(view) - binding.holder.removeView(view) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/add/AddRecipeScreen.kt b/app/src/main/java/gq/kirmanak/mealient/ui/add/AddRecipeScreen.kt new file mode 100644 index 0000000..48e6fd7 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/add/AddRecipeScreen.kt @@ -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 = {}, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/add/AddRecipeScreenEvent.kt b/app/src/main/java/gq/kirmanak/mealient/ui/add/AddRecipeScreenEvent.kt new file mode 100644 index 0000000..1ae0a55 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/add/AddRecipeScreenEvent.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/add/AddRecipeScreenState.kt b/app/src/main/java/gq/kirmanak/mealient/ui/add/AddRecipeScreenState.kt new file mode 100644 index 0000000..b3978b2 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/add/AddRecipeScreenState.kt @@ -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 = emptyList(), + val instructions: List = emptyList(), +) \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/add/AddRecipeSnackbarMessage.kt b/app/src/main/java/gq/kirmanak/mealient/ui/add/AddRecipeSnackbarMessage.kt new file mode 100644 index 0000000..dc8f824 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/add/AddRecipeSnackbarMessage.kt @@ -0,0 +1,8 @@ +package gq.kirmanak.mealient.ui.add + +internal sealed interface AddRecipeSnackbarMessage { + + data object Success : AddRecipeSnackbarMessage + + data object Error : AddRecipeSnackbarMessage +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/add/AddRecipeViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/add/AddRecipeViewModel.kt index 3890df7..6ac8514 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/add/AddRecipeViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/add/AddRecipeViewModel.kt @@ -1,64 +1,210 @@ package gq.kirmanak.mealient.ui.add +import androidx.annotation.VisibleForTesting import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import gq.kirmanak.mealient.data.add.AddRecipeRepo import gq.kirmanak.mealient.datasource.models.AddRecipeInfo +import gq.kirmanak.mealient.datasource.models.AddRecipeIngredientInfo +import gq.kirmanak.mealient.datasource.models.AddRecipeInstructionInfo +import gq.kirmanak.mealient.datasource.models.AddRecipeSettingsInfo import gq.kirmanak.mealient.datasource.runCatchingExceptCancel import gq.kirmanak.mealient.logging.Logger -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class AddRecipeViewModel @Inject constructor( +internal class AddRecipeViewModel @Inject constructor( private val addRecipeRepo: AddRecipeRepo, private val logger: Logger, ) : ViewModel() { - private val _addRecipeResultChannel = Channel(Channel.UNLIMITED) - val addRecipeResult: Flow get() = _addRecipeResultChannel.receiveAsFlow() + private val _screenState = MutableStateFlow(AddRecipeScreenState()) + val screenState: StateFlow get() = _screenState.asStateFlow() - private val _preservedAddRecipeRequestChannel = Channel(Channel.UNLIMITED) - val preservedAddRecipeRequest: Flow - get() = _preservedAddRecipeRequestChannel.receiveAsFlow() - - fun loadPreservedRequest() { - logger.v { "loadPreservedRequest() called" } + init { viewModelScope.launch { doLoadPreservedRequest() } } - private suspend fun doLoadPreservedRequest() { + @VisibleForTesting + suspend fun doLoadPreservedRequest() { logger.v { "doLoadPreservedRequest() called" } val request = addRecipeRepo.addRecipeRequestFlow.first() logger.d { "doLoadPreservedRequest: request = $request" } - _preservedAddRecipeRequestChannel.send(request) - } - - fun clear() { - logger.v { "clear() called" } - viewModelScope.launch { - addRecipeRepo.clear() - doLoadPreservedRequest() + _screenState.update { state -> + state.copy( + recipeNameInput = request.name, + recipeDescriptionInput = request.description, + recipeYieldInput = request.recipeYield, + isPublicRecipe = request.settings.public, + disableComments = request.settings.disableComments, + ingredients = request.recipeIngredient.map { it.note }, + instructions = request.recipeInstructions.map { it.text }, + saveButtonEnabled = request.name.isNotBlank(), + ) } } - fun preserve(request: AddRecipeInfo) { - logger.v { "preserve() called with: request = $request" } - viewModelScope.launch { addRecipeRepo.preserve(request) } + fun onEvent(event: AddRecipeScreenEvent) { + logger.v { "onEvent() called with: event = $event" } + when (event) { + is AddRecipeScreenEvent.AddIngredientClick -> { + _screenState.update { + it.copy(ingredients = it.ingredients + "") + } + } + + is AddRecipeScreenEvent.AddInstructionClick -> { + _screenState.update { + it.copy(instructions = it.instructions + "") + } + } + + is AddRecipeScreenEvent.DisableCommentsToggle -> { + _screenState.update { + it.copy(disableComments = !it.disableComments) + } + preserve() + } + + is AddRecipeScreenEvent.PublicRecipeToggle -> { + _screenState.update { + it.copy(isPublicRecipe = !it.isPublicRecipe) + } + preserve() + } + + is AddRecipeScreenEvent.ClearInputClick -> { + logger.v { "clear() called" } + viewModelScope.launch { + addRecipeRepo.clear() + doLoadPreservedRequest() + } + } + + is AddRecipeScreenEvent.IngredientInput -> { + _screenState.update { + val mutableIngredientsList = it.ingredients.toMutableList() + mutableIngredientsList[event.ingredientIndex] = event.input + it.copy(ingredients = mutableIngredientsList) + } + preserve() + } + + is AddRecipeScreenEvent.InstructionInput -> { + _screenState.update { + val mutableInstructionsList = it.instructions.toMutableList() + mutableInstructionsList[event.instructionIndex] = event.input + it.copy(instructions = mutableInstructionsList) + } + preserve() + } + + is AddRecipeScreenEvent.RecipeDescriptionInput -> { + _screenState.update { + it.copy(recipeDescriptionInput = event.input) + } + preserve() + } + + is AddRecipeScreenEvent.RecipeNameInput -> { + _screenState.update { + it.copy( + recipeNameInput = event.input, + saveButtonEnabled = event.input.isNotBlank(), + ) + } + preserve() + } + + is AddRecipeScreenEvent.RecipeYieldInput -> { + _screenState.update { + it.copy(recipeYieldInput = event.input) + } + preserve() + } + + is AddRecipeScreenEvent.SaveRecipeClick -> { + saveRecipe() + } + + is AddRecipeScreenEvent.SnackbarShown -> { + _screenState.update { + it.copy(snackbarMessage = null) + } + } + + is AddRecipeScreenEvent.RemoveIngredientClick -> { + _screenState.update { + val mutableIngredientsList = it.ingredients.toMutableList() + mutableIngredientsList.removeAt(event.ingredientIndex) + it.copy(ingredients = mutableIngredientsList) + } + preserve() + } + + is AddRecipeScreenEvent.RemoveInstructionClick -> { + _screenState.update { + val mutableInstructionsList = it.instructions.toMutableList() + mutableInstructionsList.removeAt(event.instructionIndex) + it.copy(instructions = mutableInstructionsList) + } + preserve() + } + } } - fun saveRecipe() { + private fun saveRecipe() { logger.v { "saveRecipe() called" } + _screenState.update { + it.copy( + isLoading = true, + saveButtonEnabled = false, + clearButtonEnabled = false, + ) + } viewModelScope.launch { - val isSuccessful = runCatchingExceptCancel { addRecipeRepo.saveRecipe() } - .fold(onSuccess = { true }, onFailure = { false }) - logger.d { "saveRecipe: isSuccessful = $isSuccessful" } - _addRecipeResultChannel.send(isSuccessful) + val isSuccessful = runCatchingExceptCancel { addRecipeRepo.saveRecipe() }.isSuccess + _screenState.update { + it.copy( + isLoading = false, + saveButtonEnabled = true, + clearButtonEnabled = true, + snackbarMessage = if (isSuccessful) { + AddRecipeSnackbarMessage.Success + } else { + AddRecipeSnackbarMessage.Error + } + ) + } + } + } + + private fun preserve() { + logger.v { "preserve() called" } + viewModelScope.launch { + val request = AddRecipeInfo( + name = screenState.value.recipeNameInput, + description = screenState.value.recipeDescriptionInput, + recipeYield = screenState.value.recipeYieldInput, + recipeIngredient = screenState.value.ingredients.map { + AddRecipeIngredientInfo(it) + }, + recipeInstructions = screenState.value.instructions.map { + AddRecipeInstructionInfo(it) + }, + settings = AddRecipeSettingsInfo( + public = screenState.value.isPublicRecipe, + disableComments = screenState.value.disableComments, + ) + ) + addRecipeRepo.preserve(request) } } } diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationFragment.kt deleted file mode 100644 index 4928d1b..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationFragment.kt +++ /dev/null @@ -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() - private val activityViewModel by activityViewModels() - - @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) = 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) - } -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationScreen.kt b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationScreen.kt new file mode 100644 index 0000000..c30c7f5 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationScreen.kt @@ -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 = {}, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationScreenEvent.kt b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationScreenEvent.kt new file mode 100644 index 0000000..67bd7ac --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationScreenEvent.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationScreenState.kt b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationScreenState.kt new file mode 100644 index 0000000..0d9429f --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationScreenState.kt @@ -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, +) \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationViewModel.kt index 6bc3f05..cf7e8bc 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationViewModel.kt @@ -1,33 +1,110 @@ package gq.kirmanak.mealient.ui.auth -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData +import android.app.Application import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import gq.kirmanak.mealient.R import gq.kirmanak.mealient.data.auth.AuthRepo +import gq.kirmanak.mealient.datasource.NetworkError import gq.kirmanak.mealient.datasource.runCatchingExceptCancel import gq.kirmanak.mealient.logging.Logger -import gq.kirmanak.mealient.ui.OperationUiState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class AuthenticationViewModel @Inject constructor( +internal class AuthenticationViewModel @Inject constructor( + private val application: Application, private val authRepo: AuthRepo, private val logger: Logger, ) : ViewModel() { - private val _uiState = MutableLiveData>(OperationUiState.Initial()) - val uiState: LiveData> get() = _uiState + private val _screenState = MutableStateFlow(AuthenticationScreenState()) + val screenState = _screenState.asStateFlow() - fun authenticate(email: String, password: String) { - logger.v { "authenticate() called" } - _uiState.value = OperationUiState.Progress() - viewModelScope.launch { - val result = runCatchingExceptCancel { authRepo.authenticate(email, password) } - logger.d { "Authentication result = $result" } - _uiState.value = OperationUiState.fromResult(result) + fun onEvent(event: AuthenticationScreenEvent) { + logger.v { "onEvent() called with: event = $event" } + when (event) { + is AuthenticationScreenEvent.OnLoginClick -> { + onLoginClick() + } + + is AuthenticationScreenEvent.OnEmailInput -> { + onEmailInput(event.input) + } + + is AuthenticationScreenEvent.OnPasswordInput -> { + onPasswordInput(event.input) + } + + AuthenticationScreenEvent.TogglePasswordVisibility -> { + togglePasswordVisibility() + } } } + + private fun togglePasswordVisibility() { + _screenState.update { + it.copy(isPasswordVisible = !it.isPasswordVisible) + } + } + + private fun onPasswordInput(passwordInput: String) { + _screenState.update { + it.copy( + passwordInput = passwordInput, + buttonEnabled = passwordInput.isNotEmpty() && it.emailInput.isNotEmpty(), + ) + } + } + + private fun onEmailInput(emailInput: String) { + _screenState.update { + it.copy( + emailInput = emailInput.trim(), + buttonEnabled = emailInput.isNotEmpty() && it.passwordInput.isNotEmpty(), + ) + } + } + + private fun onLoginClick() { + val screenState = _screenState.updateAndGet { + it.copy( + isLoading = true, + errorText = null, + buttonEnabled = false, + ) + } + viewModelScope.launch { + val result = runCatchingExceptCancel { + authRepo.authenticate( + email = screenState.emailInput, + password = screenState.passwordInput + ) + } + logger.d { "onLoginClick: result = $result" } + val errorText = result.fold( + onSuccess = { null }, + onFailure = { + when (it) { + is NetworkError.Unauthorized -> application.getString(R.string.fragment_authentication_credentials_incorrect) + else -> it.message + } + } + ) + _screenState.update { + it.copy( + isLoading = false, + isSuccessful = result.isSuccess, + errorText = errorText, + buttonEnabled = true, + ) + } + } + } + } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/auth/Extensions.kt b/app/src/main/java/gq/kirmanak/mealient/ui/auth/Extensions.kt new file mode 100644 index 0000000..cefbb4e --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/auth/Extensions.kt @@ -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, + 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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/auth/PasswordInput.kt b/app/src/main/java/gq/kirmanak/mealient/ui/auth/PasswordInput.kt new file mode 100644 index 0000000..020d7c8 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/auth/PasswordInput.kt @@ -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, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLFragment.kt deleted file mode 100644 index 63893c1..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLFragment.kt +++ /dev/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() - private val activityViewModel by activityViewModels() - private val args by navArgs() - - @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) = 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) - } -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLScreen.kt b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLScreen.kt new file mode 100644 index 0000000..41b8cba --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLScreen.kt @@ -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 = {}, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLScreenEvent.kt b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLScreenEvent.kt new file mode 100644 index 0000000..58d51e0 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLScreenEvent.kt @@ -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 +} diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLScreenState.kt b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLScreenState.kt new file mode 100644 index 0000000..39ec300 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLScreenState.kt @@ -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, + ) +} diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModel.kt index 5670905..e5fea75 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModel.kt @@ -1,10 +1,10 @@ package gq.kirmanak.mealient.ui.baseurl -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel +import android.app.Application +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import gq.kirmanak.mealient.R import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo import gq.kirmanak.mealient.data.baseurl.impl.BaseUrlLogRedactor @@ -14,32 +14,50 @@ import gq.kirmanak.mealient.datasource.NetworkError import gq.kirmanak.mealient.datasource.TrustedCertificatesStore import gq.kirmanak.mealient.datasource.findCauseAsInstanceOf import gq.kirmanak.mealient.logging.Logger -import gq.kirmanak.mealient.ui.OperationUiState -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.security.cert.X509Certificate import javax.inject.Inject @HiltViewModel -class BaseURLViewModel @Inject constructor( +internal class BaseURLViewModel @Inject constructor( + private val application: Application, private val serverInfoRepo: ServerInfoRepo, private val authRepo: AuthRepo, private val recipeRepo: RecipeRepo, private val logger: Logger, private val trustedCertificatesStore: TrustedCertificatesStore, private val baseUrlLogRedactor: BaseUrlLogRedactor, -) : ViewModel() { +) : AndroidViewModel(application) { - private val _uiState = MutableLiveData>(OperationUiState.Initial()) - val uiState: LiveData> get() = _uiState + private val _screenState = MutableStateFlow(BaseURLScreenState()) + val screenState = _screenState.asStateFlow() - private val invalidCertificatesChannel = Channel(Channel.UNLIMITED) - val invalidCertificatesFlow = invalidCertificatesChannel.receiveAsFlow() + init { + checkIfNavigationIsAllowed() + } + + private fun checkIfNavigationIsAllowed() { + logger.v { "checkIfNavigationIsAllowed() called" } + viewModelScope.launch { + val allowed = serverInfoRepo.getUrl() != null + logger.d { "checkIfNavigationIsAllowed: allowed = $allowed" } + _screenState.update { it.copy(isNavigationEnabled = allowed) } + } + } fun saveBaseUrl(baseURL: String) { logger.v { "saveBaseUrl() called" } - _uiState.value = OperationUiState.Progress() + _screenState.update { + it.copy( + isLoading = true, + errorText = null, + invalidCertificateDialogState = null, + isButtonEnabled = false, + ) + } viewModelScope.launch { checkBaseURL(baseURL) } } @@ -55,7 +73,7 @@ class BaseURLViewModel @Inject constructor( logger.d { "checkBaseURL: Created URL = \"$url\", with prefix = \"$urlWithPrefix\"" } if (url == serverInfoRepo.getUrl()) { logger.d { "checkBaseURL: new URL matches current" } - _uiState.value = OperationUiState.fromResult(Result.success(Unit)) + displayCheckUrlSuccess() return } @@ -63,7 +81,6 @@ class BaseURLViewModel @Inject constructor( logger.e(it) { "checkBaseURL: trying to recover, had prefix = $hasPrefix" } val certificateError = it.findCauseAsInstanceOf() if (certificateError != null) { - invalidCertificatesChannel.send(certificateError.serverCert) throw certificateError } else if (hasPrefix || it is NetworkError.NotMealie) { throw it @@ -79,11 +96,113 @@ class BaseURLViewModel @Inject constructor( } logger.i { "checkBaseURL: result is $result" } - _uiState.value = OperationUiState.fromResult(result) + + result.fold( + onSuccess = { displayCheckUrlSuccess() }, + onFailure = { displayCheckUrlError(it) }, + ) } - fun acceptInvalidCertificate(certificate: X509Certificate) { + private fun displayCheckUrlSuccess() { + logger.v { "displayCheckUrlSuccess() called" } + _screenState.update { + it.copy( + isConfigured = true, + isLoading = false, + isButtonEnabled = true, + errorText = null, + invalidCertificateDialogState = null, + ) + } + } + + private fun displayCheckUrlError(exception: Throwable) { + logger.v { "displayCheckUrlError() called with: exception = $exception" } + val errorText = getErrorText(exception) + val invalidCertificateDialogState = if (exception is CertificateCombinedException) { + buildInvalidCertificateDialog(exception) + } else { + null + } + _screenState.update { + it.copy( + errorText = errorText, + isButtonEnabled = true, + isLoading = false, + invalidCertificateDialogState = invalidCertificateDialogState, + ) + } + } + + private fun buildInvalidCertificateDialog( + exception: CertificateCombinedException, + ): BaseURLScreenState.InvalidCertificateDialogState { + logger.v { "buildInvalidCertificateDialog() called with: exception = $exception" } + val certificate = exception.serverCert + val message = application.getString( + R.string.fragment_base_url_invalid_certificate_message, + certificate.issuerDN.toString(), + certificate.subjectDN.toString(), + certificate.notBefore.toString(), + certificate.notAfter.toString(), + ) + return BaseURLScreenState.InvalidCertificateDialogState( + message = message, + onAcceptEvent = BaseURLScreenEvent.OnInvalidCertificateDialogAccept( + certificate = exception.serverCert, + ), + ) + } + + private fun getErrorText(throwable: Throwable): String { + logger.v { "getErrorText() called with: throwable = $throwable" } + return when (throwable) { + is NetworkError.NoServerConnection -> application.getString(R.string.fragment_base_url_no_connection) + is NetworkError.NotMealie -> application.getString(R.string.fragment_base_url_unexpected_response) + is CertificateCombinedException -> application.getString(R.string.fragment_base_url_invalid_certificate_title) + is NetworkError.MalformedUrl -> { + val cause = throwable.cause?.message ?: throwable.message + application.getString(R.string.fragment_base_url_malformed_url, cause) + } + + else -> application.getString(R.string.fragment_base_url_unknown_error) + } + } + + private fun acceptInvalidCertificate(certificate: X509Certificate) { logger.v { "acceptInvalidCertificate() called with: certificate = $certificate" } trustedCertificatesStore.addTrustedCertificate(certificate) } + + fun onEvent(event: BaseURLScreenEvent) { + logger.v { "onEvent() called with: event = $event" } + when (event) { + is BaseURLScreenEvent.OnProceedClick -> { + saveBaseUrl(_screenState.value.userInput) + } + + is BaseURLScreenEvent.OnUserInput -> { + _screenState.update { + it.copy( + userInput = event.input.trim(), + isButtonEnabled = event.input.isNotEmpty(), + ) + } + } + + is BaseURLScreenEvent.OnInvalidCertificateDialogAccept -> { + _screenState.update { + it.copy( + invalidCertificateDialogState = null, + errorText = null, + ) + } + acceptInvalidCertificate(event.certificate) + } + + is BaseURLScreenEvent.OnInvalidCertificateDialogDismiss -> { + _screenState.update { it.copy(invalidCertificateDialogState = null) } + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/InvalidCertificateDialog.kt b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/InvalidCertificateDialog.kt new file mode 100644 index 0000000..c3d732c --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/InvalidCertificateDialog.kt @@ -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 = {}, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerFragment.kt deleted file mode 100644 index 87ad600..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerFragment.kt +++ /dev/null @@ -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() - private val activityViewModel by activityViewModels() - - @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 - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerScreen.kt b/app/src/main/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerScreen.kt new file mode 100644 index 0000000..cd4dbf8 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerScreen.kt @@ -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 = {}, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerScreenState.kt b/app/src/main/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerScreenState.kt new file mode 100644 index 0000000..fdd5086 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerScreenState.kt @@ -0,0 +1,6 @@ +package gq.kirmanak.mealient.ui.disclaimer + +internal data class DisclaimerScreenState( + val isCountDownOver: Boolean, + val countDown: Int, +) diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerViewModel.kt index 36ad43f..53d407b 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerViewModel.kt @@ -1,31 +1,56 @@ package gq.kirmanak.mealient.ui.disclaimer import androidx.annotation.VisibleForTesting -import androidx.lifecycle.* +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage import gq.kirmanak.mealient.logging.Logger import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch import java.util.concurrent.TimeUnit import javax.inject.Inject @HiltViewModel -class DisclaimerViewModel @Inject constructor( +internal class DisclaimerViewModel @Inject constructor( private val disclaimerStorage: DisclaimerStorage, private val logger: Logger, ) : ViewModel() { - val isAccepted: LiveData - get() = disclaimerStorage.isDisclaimerAcceptedFlow.asLiveData() - private val _okayCountDown = MutableLiveData(FULL_COUNT_DOWN_SEC) - val okayCountDown: LiveData = _okayCountDown private var isCountDownStarted = false + private val okayCountDown = MutableStateFlow(FULL_COUNT_DOWN_SEC) + + val screenState: StateFlow = okayCountDown + .map(::countDownToScreenState) + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = countDownToScreenState(okayCountDown.value) + ) + + val isAcceptedState: StateFlow + get() = disclaimerStorage + .isDisclaimerAcceptedFlow + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + private fun countDownToScreenState(countDown: Int): DisclaimerScreenState { + logger.v { "countDownToScreenState() called with: countDown = $countDown" } + return DisclaimerScreenState( + isCountDownOver = countDown == 0, + countDown = countDown, + ) + } + fun acceptDisclaimer() { logger.v { "acceptDisclaimer() called" } viewModelScope.launch { disclaimerStorage.acceptDisclaimer() } @@ -37,7 +62,7 @@ class DisclaimerViewModel @Inject constructor( isCountDownStarted = true tickerFlow(COUNT_DOWN_TICK_PERIOD_SEC.toLong(), TimeUnit.SECONDS) .take(FULL_COUNT_DOWN_SEC - COUNT_DOWN_TICK_PERIOD_SEC + 1) - .onEach { _okayCountDown.value = FULL_COUNT_DOWN_SEC - it } + .onEach { okayCountDown.value = FULL_COUNT_DOWN_SEC - it } .launchIn(viewModelScope) } diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoFragment.kt deleted file mode 100644 index 183d93e..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoFragment.kt +++ /dev/null @@ -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() - private val activityViewModel by activityViewModels() - - @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, - ) - } - } -} diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModel.kt index 6c0514d..8ad1df5 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModel.kt @@ -10,6 +10,7 @@ import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeInstructionEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeWithSummaryAndIngredientsAndInstructions import gq.kirmanak.mealient.logging.Logger +import gq.kirmanak.mealient.ui.navArgs import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flow @@ -17,14 +18,15 @@ import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @HiltViewModel -class RecipeInfoViewModel @Inject constructor( +internal class RecipeInfoViewModel @Inject constructor( private val recipeRepo: RecipeRepo, private val logger: Logger, private val recipeImageUrlProvider: RecipeImageUrlProvider, savedStateHandle: SavedStateHandle, ) : ViewModel() { - private val args = RecipeInfoFragmentArgs.fromSavedStateHandle(savedStateHandle) + private val args = savedStateHandle.navArgs() + private val _uiState = flow { logger.v { "Initializing UI state with args = $args" } val recipeInfo = recipeRepo.loadRecipeInfo(args.recipeId) diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeScreen.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeScreen.kt index 5777e86..2a63990 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeScreen.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeScreen.kt @@ -2,53 +2,71 @@ package gq.kirmanak.mealient.ui.recipes.info import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import com.ramcosta.composedestinations.annotation.Destination import gq.kirmanak.mealient.ui.AppTheme import gq.kirmanak.mealient.ui.Dimens +import gq.kirmanak.mealient.ui.components.BaseScreen import gq.kirmanak.mealient.ui.preview.ColorSchemePreview -@OptIn(ExperimentalLayoutApi::class) +data class RecipeScreenArgs( + val recipeId: String, +) + +@Destination( + navArgsDelegate = RecipeScreenArgs::class, +) @Composable -fun RecipeScreen( - uiState: RecipeInfoUiState, +internal fun RecipeScreen( + viewModel: RecipeInfoViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsState() + + BaseScreen { modifier -> + RecipeScreen( + modifier = modifier, + state = state, + ) + } +} + +@Composable +private fun RecipeScreen( + state: RecipeInfoUiState, + modifier: Modifier = Modifier, ) { KeepScreenOn() - Scaffold { padding -> - Column( - modifier = Modifier - .verticalScroll( - state = rememberScrollState(), - ) - .padding(padding) - .consumeWindowInsets(padding), - verticalArrangement = Arrangement.spacedBy(Dimens.Small, Alignment.Top), - ) { - HeaderSection( - imageUrl = uiState.imageUrl, - title = uiState.title, - description = uiState.description, + Column( + modifier = modifier + .verticalScroll( + state = rememberScrollState(), + ), + verticalArrangement = Arrangement.spacedBy(Dimens.Small, Alignment.Top), + ) { + HeaderSection( + imageUrl = state.imageUrl, + title = state.title, + description = state.description, + ) + + if (state.showIngredients) { + IngredientsSection( + ingredients = state.recipeIngredients, ) + } - if (uiState.showIngredients) { - IngredientsSection( - ingredients = uiState.recipeIngredients, - ) - } - - if (uiState.showInstructions) { - InstructionsSection( - instructions = uiState.recipeInstructions, - ) - } + if (state.showInstructions) { + InstructionsSection( + instructions = state.recipeInstructions, + ) } } } @@ -58,7 +76,7 @@ fun RecipeScreen( private fun RecipeScreenPreview() { AppTheme { RecipeScreen( - uiState = RecipeInfoUiState( + state = RecipeInfoUiState( showIngredients = true, showInstructions = true, summaryEntity = SUMMARY_ENTITY, diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipeItem.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipeItem.kt index a46515a..4323527 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipeItem.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipeItem.kt @@ -8,6 +8,10 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material3.Card import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -103,7 +107,7 @@ private fun RecipeHeader( onClick = onDeleteClick, ) { Icon( - painter = painterResource(id = R.drawable.ic_delete), + imageVector = Icons.Default.Delete, contentDescription = stringResource(id = R.string.view_holder_recipe_delete_content_description), ) } @@ -112,15 +116,17 @@ private fun RecipeHeader( IconButton( onClick = onFavoriteClick, ) { - val resource = if (recipe.entity.isFavorite) { - R.drawable.ic_favorite_filled - } else { - R.drawable.ic_favorite_unfilled - } - Icon( - painter = painterResource(id = resource), - contentDescription = stringResource(id = R.string.view_holder_recipe_favorite_content_description), + imageVector = if (recipe.entity.isFavorite) { + Icons.Default.Favorite + } else { + Icons.Default.FavoriteBorder + }, + contentDescription = if (recipe.entity.isFavorite) { + stringResource(id = R.string.view_holder_recipe_favorite_content_description) + } else { + stringResource(id = R.string.view_holder_recipe_non_favorite_content_description) + }, ) } } diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipesList.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipesList.kt index d35ad32..c300f9a 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipesList.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipesList.kt @@ -1,15 +1,18 @@ package gq.kirmanak.mealient.ui.recipes.list +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DrawerState +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -17,43 +20,83 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController import androidx.paging.LoadState -import androidx.paging.PagingData import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemContentType import androidx.paging.compose.itemKey +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.navigate import gq.kirmanak.mealient.R +import gq.kirmanak.mealient.ui.AppTheme import gq.kirmanak.mealient.ui.Dimens +import gq.kirmanak.mealient.ui.components.BaseScreenState +import gq.kirmanak.mealient.ui.components.BaseScreenWithNavigation import gq.kirmanak.mealient.ui.components.CenteredProgressIndicator import gq.kirmanak.mealient.ui.components.LazyPagingColumnPullRefresh -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.StateFlow +import gq.kirmanak.mealient.ui.components.OpenDrawerIconButton +import gq.kirmanak.mealient.ui.destinations.RecipeScreenDestination +import gq.kirmanak.mealient.ui.preview.ColorSchemePreview -@OptIn(ExperimentalLayoutApi::class) + +@Destination @Composable internal fun RecipesList( - recipesFlow: Flow>, - onDeleteClick: (RecipeListItemState) -> Unit, - onFavoriteClick: (RecipeListItemState) -> Unit, - onItemClick: (RecipeListItemState) -> Unit, - onSnackbarShown: () -> Unit, - snackbarMessageState: StateFlow, + navController: NavController, + baseScreenState: BaseScreenState, + viewModel: RecipesListViewModel = hiltViewModel(), ) { - val recipes: LazyPagingItems = 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 = + state.pagingDataRecipeState.collectAsLazyPagingItems() val isRefreshing = recipes.loadState.refresh is LoadState.Loading var itemToDelete: RecipeListItemState? by remember { mutableStateOf(null) } val snackbarHostState = remember { SnackbarHostState() } - val snackbar: RecipeListSnackbar? by snackbarMessageState.collectAsState() - Scaffold( - snackbarHost = { SnackbarHost(snackbarHostState) }, - ) { padding -> - snackbar?.message?.let { message -> + val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) + BaseScreenWithNavigation( + baseScreenState = baseScreenState, + drawerState = drawerState, + topAppBar = { + RecipesTopAppBar( + searchQuery = state.searchQuery, + onValueChanged = { onEvent(RecipeListEvent.SearchQueryChanged(it)) }, + drawerState = drawerState, + ) + }, + snackbarHostState = snackbarHostState, + ) { modifier -> + state.snackbarState?.message?.let { message -> LaunchedEffect(message) { snackbarHostState.showSnackbar(message) - onSnackbarShown() + onEvent(RecipeListEvent.SnackbarShown) } } ?: run { snackbarHostState.currentSnackbarData?.dismiss() @@ -63,36 +106,33 @@ internal fun RecipesList( ConfirmDeleteDialog( onDismissRequest = { itemToDelete = null }, onConfirm = { - onDeleteClick(item) + onEvent(RecipeListEvent.DeleteConfirmed(item)) itemToDelete = null }, item = item, ) } - val innerModifier = Modifier - .padding(padding) - .consumeWindowInsets(padding) when { recipes.itemCount != 0 -> { RecipesListData( - modifier = innerModifier, + modifier = modifier, recipes = recipes, onDeleteClick = { itemToDelete = it }, - onFavoriteClick = onFavoriteClick, - onItemClick = onItemClick + onFavoriteClick = { onEvent(RecipeListEvent.FavoriteClick(it)) }, + onItemClick = { onEvent(RecipeListEvent.RecipeClick(it)) }, ) } isRefreshing -> { CenteredProgressIndicator( - modifier = innerModifier + modifier = modifier ) } else -> { RecipesListError( - modifier = innerModifier, + modifier = modifier, recipes = recipes, ) } @@ -126,7 +166,7 @@ private fun RecipesListData( recipes: LazyPagingItems, onDeleteClick: (RecipeListItemState) -> Unit, onFavoriteClick: (RecipeListItemState) -> Unit, - onItemClick: (RecipeListItemState) -> Unit + onItemClick: (RecipeListItemState) -> Unit, ) { LazyPagingColumnPullRefresh( modifier = modifier @@ -155,3 +195,45 @@ private fun RecipesListData( } } +@Composable +internal fun RecipesTopAppBar( + searchQuery: String, + onValueChanged: (String) -> Unit, + drawerState: DrawerState, +) { + Row( + modifier = Modifier + .padding( + horizontal = Dimens.Medium, + vertical = Dimens.Small, + ) + .clip(RoundedCornerShape(Dimens.Medium)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(end = Dimens.Medium), + verticalAlignment = Alignment.CenterVertically, + ) { + OpenDrawerIconButton( + drawerState = drawerState, + ) + + SearchTextField( + modifier = Modifier + .weight(1f), + searchQuery = searchQuery, + onValueChanged = onValueChanged, + placeholder = R.string.search_recipes_hint, + ) + } +} + +@ColorSchemePreview +@Composable +private fun RecipesTopAppBarPreview() { + AppTheme { + RecipesTopAppBar( + searchQuery = "", + onValueChanged = {}, + drawerState = rememberDrawerState(initialValue = DrawerValue.Closed), + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipesListFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipesListFragment.kt deleted file mode 100644 index dd7789c..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipesListFragment.kt +++ /dev/null @@ -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() - private val activityViewModel by activityViewModels() - - 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) - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipesListViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipesListViewModel.kt index d47f815..ba2d3e4 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipesListViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipesListViewModel.kt @@ -1,8 +1,6 @@ package gq.kirmanak.mealient.ui.recipes.list -import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel -import androidx.lifecycle.liveData import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn @@ -14,11 +12,8 @@ import gq.kirmanak.mealient.data.recipes.RecipeRepo import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProvider import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.logging.Logger -import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -26,6 +21,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -43,7 +39,7 @@ internal class RecipesListViewModel @Inject constructor( private val showFavoriteIcon: StateFlow = authRepo.isAuthorizedFlow.stateIn(viewModelScope, SharingStarted.Eagerly, false) - val pagingDataRecipeState: Flow> = + private val pagingDataRecipeState: Flow> = pagingData.combine(showFavoriteIcon) { data, showFavorite -> data.map { item -> val imageUrl = recipeImageUrlProvider.generateImageUrl(item.imageId) @@ -55,15 +51,10 @@ internal class RecipesListViewModel @Inject constructor( } } - private val _deleteRecipeResult = MutableSharedFlow>( - replay = 0, - extraBufferCapacity = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST + private val _screenState = MutableStateFlow( + RecipeListState(pagingDataRecipeState = pagingDataRecipeState) ) - val deleteRecipeResult: SharedFlow> get() = _deleteRecipeResult - - private val _snackbarState = MutableStateFlow(null) - val snackbarState get() = _snackbarState.asStateFlow() + val screenState: StateFlow get() = _screenState.asStateFlow() init { authRepo.isAuthorizedFlow.valueUpdatesOnly().onEach { hasAuthorized -> @@ -72,23 +63,23 @@ internal class RecipesListViewModel @Inject constructor( }.launchIn(viewModelScope) } - fun refreshRecipeInfo(recipeSlug: String): LiveData> { - logger.v { "refreshRecipeInfo called with: recipeSlug = $recipeSlug" } - return liveData { - val result = recipeRepo.refreshRecipeInfo(recipeSlug) - logger.v { "refreshRecipeInfo: emitting $result" } - emit(result) + private fun onRecipeClicked(entity: RecipeSummaryEntity) { + logger.v { "onRecipeClicked() called with: entity = $entity" } + viewModelScope.launch { + val result = recipeRepo.refreshRecipeInfo(entity.slug) + logger.d { "Recipe info refreshed: $result" } + _screenState.update { it.copy(recipeIdToOpen = entity.remoteId) } } } - fun onFavoriteIconClick(recipeSummaryEntity: RecipeSummaryEntity) { + private fun onFavoriteIconClick(recipeSummaryEntity: RecipeSummaryEntity) { logger.v { "onFavoriteIconClick() called with: recipeSummaryEntity = $recipeSummaryEntity" } viewModelScope.launch { val result = recipeRepo.updateIsRecipeFavorite( recipeSlug = recipeSummaryEntity.slug, isFavorite = recipeSummaryEntity.isFavorite.not(), ) - _snackbarState.value = result.fold( + val snackbar = result.fold( onSuccess = { isFavorite -> val name = recipeSummaryEntity.name if (isFavorite) { @@ -101,23 +92,69 @@ internal class RecipesListViewModel @Inject constructor( RecipeListSnackbar.FavoriteUpdateFailed } ) + _screenState.update { it.copy(snackbarState = snackbar) } } } - fun onDeleteConfirm(recipeSummaryEntity: RecipeSummaryEntity) { + private fun onDeleteConfirm(recipeSummaryEntity: RecipeSummaryEntity) { logger.v { "onDeleteConfirm() called with: recipeSummaryEntity = $recipeSummaryEntity" } viewModelScope.launch { val result = recipeRepo.deleteRecipe(recipeSummaryEntity) logger.d { "onDeleteConfirm: delete result is $result" } - _deleteRecipeResult.emit(result) - _snackbarState.value = result.fold( + val snackbar = result.fold( onSuccess = { null }, onFailure = { RecipeListSnackbar.DeleteFailed }, ) + _screenState.update { it.copy(snackbarState = snackbar) } } } - fun onSnackbarShown() { - _snackbarState.value = null + private fun onSnackbarShown() { + _screenState.update { it.copy(snackbarState = null) } } + + private fun onRecipeOpen() { + logger.v { "onRecipeOpen() called" } + _screenState.update { it.copy(recipeIdToOpen = null) } + } + + fun onEvent(event: RecipeListEvent) { + logger.v { "onEvent() called with: event = $event" } + when (event) { + is RecipeListEvent.DeleteConfirmed -> onDeleteConfirm(event.recipe.entity) + is RecipeListEvent.FavoriteClick -> onFavoriteIconClick(event.recipe.entity) + is RecipeListEvent.RecipeClick -> onRecipeClicked(event.recipe.entity) + is RecipeListEvent.SnackbarShown -> onSnackbarShown() + is RecipeListEvent.RecipeOpened -> onRecipeOpen() + is RecipeListEvent.SearchQueryChanged -> onSearchQueryChanged(event) + } + } + + private fun onSearchQueryChanged(event: RecipeListEvent.SearchQueryChanged) { + logger.v { "onSearchQueryChanged() called with: event = $event" } + _screenState.update { it.copy(searchQuery = event.query) } + recipeRepo.updateNameQuery(event.query) + } +} + +internal data class RecipeListState( + val pagingDataRecipeState: Flow>, + 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 } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/SearchTextField.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/SearchTextField.kt new file mode 100644 index 0000000..51e770b --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/SearchTextField.kt @@ -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, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/share/ShareRecipeActivity.kt b/app/src/main/java/gq/kirmanak/mealient/ui/share/ShareRecipeActivity.kt index 6b30c8d..346df2c 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/share/ShareRecipeActivity.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/share/ShareRecipeActivity.kt @@ -1,31 +1,35 @@ package gq.kirmanak.mealient.ui.share import android.content.Intent -import android.graphics.drawable.Animatable2 -import android.graphics.drawable.AnimatedVectorDrawable -import android.graphics.drawable.Drawable import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent import androidx.activity.viewModels -import androidx.core.view.isInvisible -import androidx.core.view.postDelayed +import androidx.core.view.WindowInsetsControllerCompat import dagger.hilt.android.AndroidEntryPoint import gq.kirmanak.mealient.R -import gq.kirmanak.mealient.databinding.ActivityShareRecipeBinding +import gq.kirmanak.mealient.extensions.isDarkThemeOn import gq.kirmanak.mealient.extensions.showLongToast -import gq.kirmanak.mealient.ui.BaseActivity +import gq.kirmanak.mealient.logging.Logger +import gq.kirmanak.mealient.ui.AppTheme import gq.kirmanak.mealient.ui.OperationUiState +import javax.inject.Inject @AndroidEntryPoint -class ShareRecipeActivity : BaseActivity( - binder = ActivityShareRecipeBinding::bind, - containerId = R.id.root, - layoutRes = R.layout.activity_share_recipe, -) { +class ShareRecipeActivity : ComponentActivity() { + + @Inject + lateinit var logger: Logger private val viewModel: ShareRecipeViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + with(WindowInsetsControllerCompat(window, window.decorView)) { + val isAppearanceLightBars = !isDarkThemeOn() + isAppearanceLightNavigationBars = isAppearanceLightBars + isAppearanceLightStatusBars = isAppearanceLightBars + } if (intent.action != Intent.ACTION_SEND || intent.type != "text/plain") { logger.w { "onCreate: intent.action = ${intent.action}, intent.type = ${intent.type}" } @@ -40,16 +44,17 @@ class ShareRecipeActivity : BaseActivity( return } - restartAnimationOnEnd() viewModel.saveResult.observe(this, ::onStateUpdate) viewModel.saveRecipeByURL(url) + + setContent { + AppTheme { + ShareRecipeScreen() + } + } } private fun onStateUpdate(state: OperationUiState) { - binding.progress.isInvisible = !state.isProgress - withAnimatedDrawable { - if (state.isProgress) start() else stop() - } if (state.isSuccess || state.isFailure) { showLongToast( if (state.isSuccess) R.string.activity_share_recipe_success_toast @@ -59,35 +64,5 @@ class ShareRecipeActivity : BaseActivity( } } - 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 -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/share/ShareRecipeScreen.kt b/app/src/main/java/gq/kirmanak/mealient/ui/share/ShareRecipeScreen.kt new file mode 100644 index 0000000..9a3c4c5 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/share/ShareRecipeScreen.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_toolbar.xml b/app/src/main/res/drawable/bg_toolbar.xml deleted file mode 100644 index b123a8a..0000000 --- a/app/src/main/res/drawable/bg_toolbar.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml deleted file mode 100644 index f2bce58..0000000 --- a/app/src/main/res/drawable/ic_add.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_change.xml b/app/src/main/res/drawable/ic_change.xml deleted file mode 100644 index 4039c66..0000000 --- a/app/src/main/res/drawable/ic_change.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml deleted file mode 100644 index fa259ee..0000000 --- a/app/src/main/res/drawable/ic_delete.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_favorite_filled.xml b/app/src/main/res/drawable/ic_favorite_filled.xml deleted file mode 100644 index 4f40d7a..0000000 --- a/app/src/main/res/drawable/ic_favorite_filled.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_favorite_unfilled.xml b/app/src/main/res/drawable/ic_favorite_unfilled.xml deleted file mode 100644 index c258d40..0000000 --- a/app/src/main/res/drawable/ic_favorite_unfilled.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_list.xml b/app/src/main/res/drawable/ic_list.xml deleted file mode 100644 index 69c90d2..0000000 --- a/app/src/main/res/drawable/ic_list.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_login.xml b/app/src/main/res/drawable/ic_login.xml deleted file mode 100644 index 9c9dedc..0000000 --- a/app/src/main/res/drawable/ic_login.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_logout.xml b/app/src/main/res/drawable/ic_logout.xml deleted file mode 100644 index 46542bc..0000000 --- a/app/src/main/res/drawable/ic_logout.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_menu.xml b/app/src/main/res/drawable/ic_menu.xml deleted file mode 100644 index d632557..0000000 --- a/app/src/main/res/drawable/ic_menu.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_search.xml b/app/src/main/res/drawable/ic_search.xml deleted file mode 100644 index 8cdd776..0000000 --- a/app/src/main/res/drawable/ic_search.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_send.xml b/app/src/main/res/drawable/ic_send.xml deleted file mode 100644 index 3112f6d..0000000 --- a/app/src/main/res/drawable/ic_send.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/layout/activity_share_recipe.xml b/app/src/main/res/layout/activity_share_recipe.xml deleted file mode 100644 index 7919d60..0000000 --- a/app/src/main/res/layout/activity_share_recipe.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_add_recipe.xml b/app/src/main/res/layout/fragment_add_recipe.xml deleted file mode 100644 index b2eb65b..0000000 --- a/app/src/main/res/layout/fragment_add_recipe.xml +++ /dev/null @@ -1,142 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - -