From f704a290a2838afb5fdad825ced869edafa894f2 Mon Sep 17 00:00:00 2001 From: jacqui Date: Sun, 4 Jun 2023 22:10:57 +0300 Subject: [PATCH 01/29] Adding Feed.kt file --- .../com/android254/data/repos/FeedManager.kt | 40 +++++++++++++++++++ .../java/com/android254/domain/models/Feed.kt | 10 +++++ 2 files changed, 50 insertions(+) create mode 100644 data/src/main/java/com/android254/data/repos/FeedManager.kt create mode 100644 domain/src/main/java/com/android254/domain/models/Feed.kt diff --git a/data/src/main/java/com/android254/data/repos/FeedManager.kt b/data/src/main/java/com/android254/data/repos/FeedManager.kt new file mode 100644 index 00000000..d0abc694 --- /dev/null +++ b/data/src/main/java/com/android254/data/repos/FeedManager.kt @@ -0,0 +1,40 @@ +package com.android254.data.repos + +import com.android254.data.network.apis.FeedApi +import com.android254.data.repos.mappers.toDomain +import com.android254.domain.models.DataResult +import com.android254.domain.models.Feed +import com.android254.domain.models.ResourceResult +import com.android254.domain.repos.FeedRepo +import javax.inject.Inject + +class FeedManager @Inject constructor( + private val api: FeedApi +) : FeedRepo { + override suspend fun fetchFeed(): ResourceResult> { + return when (val result = api.fetchFeed(1, 100)) { + is DataResult.Empty -> { + ResourceResult.Success(emptyList()) + } + is DataResult.Error -> { + ResourceResult.Error( + result.message, + networkError = result.message.contains("network", ignoreCase = true) + ) + } + is DataResult.Success -> { + val data = result.data + if (data.isNotEmpty()) { + ResourceResult.Empty() + } + ResourceResult.Success( + data.map { it.toDomain() } + ) + } + + else -> { + ResourceResult.Success(emptyList()) + } + } + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/android254/domain/models/Feed.kt b/domain/src/main/java/com/android254/domain/models/Feed.kt new file mode 100644 index 00000000..e085acef --- /dev/null +++ b/domain/src/main/java/com/android254/domain/models/Feed.kt @@ -0,0 +1,10 @@ +package com.android254.domain.models + +class Feed( + val title: String, + val body: String, + val topic: String, + val url: String, + val image: String?, + val createdAt: String +) \ No newline at end of file From c88bdd16597ffbb495cba15ffdb35c40aea85a86 Mon Sep 17 00:00:00 2001 From: jacqui Date: Sun, 4 Jun 2023 22:16:29 +0300 Subject: [PATCH 02/29] Adding FeedRepo.kt Interface file --- .../src/main/java/com/android254/domain/repos/FeedRepo.kt | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 domain/src/main/java/com/android254/domain/repos/FeedRepo.kt diff --git a/domain/src/main/java/com/android254/domain/repos/FeedRepo.kt b/domain/src/main/java/com/android254/domain/repos/FeedRepo.kt new file mode 100644 index 00000000..05aeebc3 --- /dev/null +++ b/domain/src/main/java/com/android254/domain/repos/FeedRepo.kt @@ -0,0 +1,8 @@ +package com.android254.domain.repos + +import com.android254.domain.models.Feed +import com.android254.domain.models.ResourceResult + +interface FeedRepo { + suspend fun fetchFeed(): ResourceResult> +} \ No newline at end of file From 68046aa224875a4e748b7dbf54af31b0e169b0cc Mon Sep 17 00:00:00 2001 From: jacqui Date: Sun, 4 Jun 2023 22:23:54 +0300 Subject: [PATCH 03/29] Renaming Feed.kt to FeedDTO.kt --- .../com/android254/data/network/apis/FeedApi.kt | 4 ++-- .../models/responses/{Feed.kt => FeedDTO.kt} | 2 +- .../android254/data/repos/mappers/FeedMappers.kt | 14 ++++++++++++++ .../android254/data/network/apis/FeedApiTest.kt | 6 +++--- 4 files changed, 20 insertions(+), 6 deletions(-) rename data/src/main/java/com/android254/data/network/models/responses/{Feed.kt => FeedDTO.kt} (98%) create mode 100644 data/src/main/java/com/android254/data/repos/mappers/FeedMappers.kt diff --git a/data/src/main/java/com/android254/data/network/apis/FeedApi.kt b/data/src/main/java/com/android254/data/network/apis/FeedApi.kt index e532925c..0c22c5df 100644 --- a/data/src/main/java/com/android254/data/network/apis/FeedApi.kt +++ b/data/src/main/java/com/android254/data/network/apis/FeedApi.kt @@ -15,7 +15,7 @@ */ package com.android254.data.network.apis -import com.android254.data.network.models.responses.Feed +import com.android254.data.network.models.responses.FeedDTO import com.android254.data.network.models.responses.PaginatedResponse import com.android254.data.network.util.dataResultSafeApiCall import com.android254.data.network.util.provideEventBaseUrl @@ -27,7 +27,7 @@ import javax.inject.Inject class FeedApi @Inject constructor(private val client: HttpClient) { suspend fun fetchFeed(page: Int = 1, size: Int = 100) = dataResultSafeApiCall { - val response: PaginatedResponse> = + val response: PaginatedResponse> = client.get("${provideEventBaseUrl()}/feeds") { url { parameters.append("page", page.toString()) diff --git a/data/src/main/java/com/android254/data/network/models/responses/Feed.kt b/data/src/main/java/com/android254/data/network/models/responses/FeedDTO.kt similarity index 98% rename from data/src/main/java/com/android254/data/network/models/responses/Feed.kt rename to data/src/main/java/com/android254/data/network/models/responses/FeedDTO.kt index 0deecc1f..f82f99c2 100644 --- a/data/src/main/java/com/android254/data/network/models/responses/Feed.kt +++ b/data/src/main/java/com/android254/data/network/models/responses/FeedDTO.kt @@ -27,7 +27,7 @@ import java.time.LocalDateTime import java.time.format.DateTimeFormatter @Serializable -data class Feed( +data class FeedDTO( val title: String, val body: String, val topic: String, diff --git a/data/src/main/java/com/android254/data/repos/mappers/FeedMappers.kt b/data/src/main/java/com/android254/data/repos/mappers/FeedMappers.kt new file mode 100644 index 00000000..b4d3b616 --- /dev/null +++ b/data/src/main/java/com/android254/data/repos/mappers/FeedMappers.kt @@ -0,0 +1,14 @@ +package com.android254.data.repos.mappers + +import com.android254.data.network.models.responses.FeedDTO +import com.android254.domain.models.Feed + + +fun FeedDTO.toDomain() = Feed( + title = title, + body = body, + topic = topic, + url = url, + image = image, + createdAt = createdAt.toString() +) \ No newline at end of file diff --git a/data/src/test/java/com/android254/data/network/apis/FeedApiTest.kt b/data/src/test/java/com/android254/data/network/apis/FeedApiTest.kt index 3b3fce75..4c622eed 100644 --- a/data/src/test/java/com/android254/data/network/apis/FeedApiTest.kt +++ b/data/src/test/java/com/android254/data/network/apis/FeedApiTest.kt @@ -15,7 +15,7 @@ */ package com.android254.data.network.apis -import com.android254.data.network.models.responses.Feed +import com.android254.data.network.models.responses.FeedDTO import com.android254.data.network.util.HttpClientFactory import com.android254.data.network.util.MockTokenProvider import com.android254.data.network.util.RemoteFeatureToggle @@ -108,7 +108,7 @@ class FeedApiTest { `is`( DataResult.Success( listOf( - Feed( + FeedDTO( title = "Test", body = "Good one", topic = "droidconweb", @@ -119,7 +119,7 @@ class FeedApiTest { LocalTime.parse("18:45:49") ) ), - Feed( + FeedDTO( title = "niko fine", body = "this is a test", topic = "droidconweb", From 5cee66f5a5393a15e90934f46394b06087f5e400 Mon Sep 17 00:00:00 2001 From: jacqui Date: Sun, 4 Jun 2023 23:03:06 +0300 Subject: [PATCH 04/29] Adding Feed ViewModel --- .../presentation/feed/FeedViewModel.kt | 38 +++++++++++++++++++ .../presentation/feed/view/FeedMappers.kt | 13 +++++++ .../presentation/feed/view/FeedUIState.kt | 9 +++++ .../android254/presentation/models/FeedUI.kt | 10 +++++ 4 files changed, 70 insertions(+) create mode 100644 presentation/src/main/java/com/android254/presentation/feed/FeedViewModel.kt create mode 100644 presentation/src/main/java/com/android254/presentation/feed/view/FeedMappers.kt create mode 100644 presentation/src/main/java/com/android254/presentation/feed/view/FeedUIState.kt create mode 100644 presentation/src/main/java/com/android254/presentation/models/FeedUI.kt diff --git a/presentation/src/main/java/com/android254/presentation/feed/FeedViewModel.kt b/presentation/src/main/java/com/android254/presentation/feed/FeedViewModel.kt new file mode 100644 index 00000000..e2161697 --- /dev/null +++ b/presentation/src/main/java/com/android254/presentation/feed/FeedViewModel.kt @@ -0,0 +1,38 @@ +package com.android254.presentation.feed + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android254.domain.models.ResourceResult +import com.android254.domain.repos.FeedRepo +import com.android254.presentation.feed.view.FeedUIState +import com.android254.presentation.feed.view.toPresentation +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class FeedViewModel @Inject constructor( + private val feedRepo: FeedRepo +) : ViewModel() { + private val _feedsState = MutableStateFlow(FeedUIState.Loading) + val feedsState: StateFlow get() = _feedsState + + fun fetchFeed() { + viewModelScope.launch { + when (val value = feedRepo.fetchFeed()) { + is ResourceResult.Error -> _feedsState.value = FeedUIState.Error(value.message) + is ResourceResult.Loading -> _feedsState.value = FeedUIState.Loading + is ResourceResult.Success -> { + value.data?.let { + _feedsState.value = FeedUIState.Success( + it.map { feed -> feed.toPresentation() } + ) + } + } + else -> _feedsState.value = FeedUIState.Error("Unknown") + } + } + } +} \ No newline at end of file diff --git a/presentation/src/main/java/com/android254/presentation/feed/view/FeedMappers.kt b/presentation/src/main/java/com/android254/presentation/feed/view/FeedMappers.kt new file mode 100644 index 00000000..6e25ab7e --- /dev/null +++ b/presentation/src/main/java/com/android254/presentation/feed/view/FeedMappers.kt @@ -0,0 +1,13 @@ +package com.android254.presentation.feed.view + +import com.android254.domain.models.Feed +import com.android254.presentation.models.FeedUI + +fun Feed.toPresentation() = FeedUI( + title = title, + body = body, + topic = topic, + url = url, + image = image, + createdAt = createdAt +) \ No newline at end of file diff --git a/presentation/src/main/java/com/android254/presentation/feed/view/FeedUIState.kt b/presentation/src/main/java/com/android254/presentation/feed/view/FeedUIState.kt new file mode 100644 index 00000000..b4c8b0f3 --- /dev/null +++ b/presentation/src/main/java/com/android254/presentation/feed/view/FeedUIState.kt @@ -0,0 +1,9 @@ +package com.android254.presentation.feed.view + +import com.android254.presentation.models.FeedUI + +sealed interface FeedUIState { + object Loading : FeedUIState + data class Error(val message: String) : FeedUIState + data class Success(val feeds: List) : FeedUIState +} \ No newline at end of file diff --git a/presentation/src/main/java/com/android254/presentation/models/FeedUI.kt b/presentation/src/main/java/com/android254/presentation/models/FeedUI.kt new file mode 100644 index 00000000..0b2d88cc --- /dev/null +++ b/presentation/src/main/java/com/android254/presentation/models/FeedUI.kt @@ -0,0 +1,10 @@ +package com.android254.presentation.models + +data class FeedUI( + val title: String, + val body: String, + val topic: String, + val url: String, + val image: String?, + val createdAt: String +) From db7ea8e8bcfa96307d7a586c7a43094555dfeb0e Mon Sep 17 00:00:00 2001 From: jacqui Date: Tue, 6 Jun 2023 22:31:07 +0300 Subject: [PATCH 05/29] Adds feed implementation --- .../java/com/android254/data/di/RepoModule.kt | 6 + .../android254/data/network/apis/FeedApi.kt | 10 +- .../com/android254/data/repos/FeedManager.kt | 19 +- .../data/repos/mappers/FeedMappers.kt | 16 +- .../java/com/android254/domain/models/Feed.kt | 15 + .../com/android254/domain/repos/FeedRepo.kt | 15 + .../common/bottomsheet/BottomSheetScaffold.kt | 441 +++++++++ .../presentation/common/bottomsheet/Drawer.kt | 685 ++++++++++++++ .../common/bottomsheet/Strings.kt | 52 + .../common/bottomsheet/Swipeable.kt | 886 ++++++++++++++++++ .../presentation/feed/FeedViewModel.kt | 17 +- .../presentation/feed/view/FeedComponent.kt | 37 +- .../presentation/feed/view/FeedMappers.kt | 15 + .../presentation/feed/view/FeedScreen.kt | 83 +- .../presentation/feed/view/FeedUIState.kt | 15 + .../presentation/home/screen/HomeScreen.kt | 4 +- .../android254/presentation/models/FeedUI.kt | 17 +- .../sessions/view/SessionsViewModel.kt | 8 +- presentation/src/main/res/values/strings.xml | 2 + 19 files changed, 2276 insertions(+), 67 deletions(-) create mode 100644 presentation/src/main/java/com/android254/presentation/common/bottomsheet/BottomSheetScaffold.kt create mode 100644 presentation/src/main/java/com/android254/presentation/common/bottomsheet/Drawer.kt create mode 100644 presentation/src/main/java/com/android254/presentation/common/bottomsheet/Strings.kt create mode 100644 presentation/src/main/java/com/android254/presentation/common/bottomsheet/Swipeable.kt diff --git a/data/src/main/java/com/android254/data/di/RepoModule.kt b/data/src/main/java/com/android254/data/di/RepoModule.kt index c6c4e7fd..abea8f5d 100644 --- a/data/src/main/java/com/android254/data/di/RepoModule.kt +++ b/data/src/main/java/com/android254/data/di/RepoModule.kt @@ -16,11 +16,13 @@ package com.android254.data.di import com.android254.data.repos.AuthManager +import com.android254.data.repos.FeedManager import com.android254.data.repos.HomeRepoImpl import com.android254.data.repos.OrganizersSource import com.android254.data.repos.SessionsManager import com.android254.data.repos.SpeakersManager import com.android254.domain.repos.AuthRepo +import com.android254.domain.repos.FeedRepo import com.android254.domain.repos.HomeRepo import com.android254.domain.repos.OrganizersRepository import com.android254.domain.repos.SessionsRepo @@ -54,4 +56,8 @@ abstract class RepoModule { @Binds @Singleton abstract fun provideOrganizersRepo(source: OrganizersSource): OrganizersRepository + + @Binds + @Singleton + abstract fun provideFeedRepo(manager: FeedManager): FeedRepo } \ No newline at end of file diff --git a/data/src/main/java/com/android254/data/network/apis/FeedApi.kt b/data/src/main/java/com/android254/data/network/apis/FeedApi.kt index 0c22c5df..9b832f45 100644 --- a/data/src/main/java/com/android254/data/network/apis/FeedApi.kt +++ b/data/src/main/java/com/android254/data/network/apis/FeedApi.kt @@ -18,17 +18,17 @@ package com.android254.data.network.apis import com.android254.data.network.models.responses.FeedDTO import com.android254.data.network.models.responses.PaginatedResponse import com.android254.data.network.util.dataResultSafeApiCall -import com.android254.data.network.util.provideEventBaseUrl -import io.ktor.client.* -import io.ktor.client.call.* -import io.ktor.client.request.* +import com.android254.data.network.util.provideBaseUrl +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get import javax.inject.Inject class FeedApi @Inject constructor(private val client: HttpClient) { suspend fun fetchFeed(page: Int = 1, size: Int = 100) = dataResultSafeApiCall { val response: PaginatedResponse> = - client.get("${provideEventBaseUrl()}/feeds") { + client.get("${provideBaseUrl()}/feeds") { url { parameters.append("page", page.toString()) parameters.append("per_page", size.toString()) diff --git a/data/src/main/java/com/android254/data/repos/FeedManager.kt b/data/src/main/java/com/android254/data/repos/FeedManager.kt index d0abc694..7b5cca25 100644 --- a/data/src/main/java/com/android254/data/repos/FeedManager.kt +++ b/data/src/main/java/com/android254/data/repos/FeedManager.kt @@ -1,3 +1,18 @@ +/* + * Copyright 2023 DroidconKE + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package com.android254.data.repos import com.android254.data.network.apis.FeedApi @@ -24,9 +39,7 @@ class FeedManager @Inject constructor( } is DataResult.Success -> { val data = result.data - if (data.isNotEmpty()) { - ResourceResult.Empty() - } + ResourceResult.Success( data.map { it.toDomain() } ) diff --git a/data/src/main/java/com/android254/data/repos/mappers/FeedMappers.kt b/data/src/main/java/com/android254/data/repos/mappers/FeedMappers.kt index b4d3b616..cdda6bda 100644 --- a/data/src/main/java/com/android254/data/repos/mappers/FeedMappers.kt +++ b/data/src/main/java/com/android254/data/repos/mappers/FeedMappers.kt @@ -1,9 +1,23 @@ +/* + * Copyright 2023 DroidconKE + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package com.android254.data.repos.mappers import com.android254.data.network.models.responses.FeedDTO import com.android254.domain.models.Feed - fun FeedDTO.toDomain() = Feed( title = title, body = body, diff --git a/domain/src/main/java/com/android254/domain/models/Feed.kt b/domain/src/main/java/com/android254/domain/models/Feed.kt index e085acef..2199abba 100644 --- a/domain/src/main/java/com/android254/domain/models/Feed.kt +++ b/domain/src/main/java/com/android254/domain/models/Feed.kt @@ -1,3 +1,18 @@ +/* + * Copyright 2023 DroidconKE + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package com.android254.domain.models class Feed( diff --git a/domain/src/main/java/com/android254/domain/repos/FeedRepo.kt b/domain/src/main/java/com/android254/domain/repos/FeedRepo.kt index 05aeebc3..78eb68c7 100644 --- a/domain/src/main/java/com/android254/domain/repos/FeedRepo.kt +++ b/domain/src/main/java/com/android254/domain/repos/FeedRepo.kt @@ -1,3 +1,18 @@ +/* + * Copyright 2023 DroidconKE + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package com.android254.domain.repos import com.android254.domain.models.Feed diff --git a/presentation/src/main/java/com/android254/presentation/common/bottomsheet/BottomSheetScaffold.kt b/presentation/src/main/java/com/android254/presentation/common/bottomsheet/BottomSheetScaffold.kt new file mode 100644 index 00000000..f38121a8 --- /dev/null +++ b/presentation/src/main/java/com/android254/presentation/common/bottomsheet/BottomSheetScaffold.kt @@ -0,0 +1,441 @@ +/* + * Copyright 2023 DroidconKE + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android254.presentation.common.bottomsheet + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.requiredHeightIn +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.collapse +import androidx.compose.ui.semantics.expand +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +/** + * Possible values of [BottomSheetState]. + */ +enum class BottomSheetValue { + /** + * The bottom sheet is visible, but only showing its peek height. + */ + Collapsed, + + /** + * The bottom sheet is visible at its maximum height. + */ + Expanded +} + +/** + * State of the persistent bottom sheet in [BottomSheetScaffold]. + * + * @param initialValue The initial value of the state. + * @param animationSpec The default animation that will be used to animate to a new state. + * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Stable +class BottomSheetState( + initialValue: BottomSheetValue, + animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, + confirmStateChange: (BottomSheetValue) -> Boolean = { true } +) : SwipeableState( + initialValue = initialValue, + animationSpec = animationSpec, + confirmStateChange = confirmStateChange +) { + /** + * Whether the bottom sheet is expanded. + */ + val isExpanded: Boolean + get() = currentValue == BottomSheetValue.Expanded + + /** + * Whether the bottom sheet is collapsed. + */ + val isCollapsed: Boolean + get() = currentValue == BottomSheetValue.Collapsed + + /** + * Expand the bottom sheet with animation and suspend until it if fully expanded or animation + * has been cancelled. This method will throw [CancellationException] if the animation is + * interrupted + * + * @return the reason the expand animation ended + */ + suspend fun expand() = animateTo(BottomSheetValue.Expanded) + + /** + * Collapse the bottom sheet with animation and suspend until it if fully collapsed or animation + * has been cancelled. This method will throw [CancellationException] if the animation is + * interrupted + * + * @return the reason the collapse animation ended + */ + suspend fun collapse() = animateTo(BottomSheetValue.Collapsed) + + companion object { + /** + * The default [Saver] implementation for [BottomSheetState]. + */ + fun Saver( + animationSpec: AnimationSpec, + confirmStateChange: (BottomSheetValue) -> Boolean + ): Saver = Saver( + save = { it.currentValue }, + restore = { + BottomSheetState( + initialValue = it, + animationSpec = animationSpec, + confirmStateChange = confirmStateChange + ) + } + ) + } + + internal val nestedScrollConnection = this.PreUpPostDownNestedScrollConnection +} + +/** + * Create a [BottomSheetState] and [remember] it. + * + * @param initialValue The initial value of the state. + * @param animationSpec The default animation that will be used to animate to a new state. + * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change. + */ +@Composable +fun rememberBottomSheetState( + initialValue: BottomSheetValue, + animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, + confirmStateChange: (BottomSheetValue) -> Boolean = { true } +): BottomSheetState { + return rememberSaveable( + animationSpec, + saver = BottomSheetState.Saver( + animationSpec = animationSpec, + confirmStateChange = confirmStateChange + ) + ) { + BottomSheetState( + initialValue = initialValue, + animationSpec = animationSpec, + confirmStateChange = confirmStateChange + ) + } +} + +/** + * State of the [BottomSheetScaffold] composable. + * + * @param drawerState The state of the navigation drawer. + * @param bottomSheetState The state of the persistent bottom sheet. + * @param snackbarHostState The [SnackbarHostState] used to show snackbars inside the scaffold. + */ +@Stable +class BottomSheetScaffoldState( + val drawerState: DrawerState, + val bottomSheetState: BottomSheetState, + val snackbarHostState: SnackbarHostState +) + +/** + * Create and [remember] a [BottomSheetScaffoldState]. + * + * @param drawerState The state of the navigation drawer. + * @param bottomSheetState The state of the persistent bottom sheet. + * @param snackbarHostState The [SnackbarHostState] used to show snackbars inside the scaffold. + */ +@Composable +fun rememberBottomSheetScaffoldState( + drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed), + bottomSheetState: BottomSheetState = rememberBottomSheetState(BottomSheetValue.Collapsed), + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } +): BottomSheetScaffoldState { + return remember(drawerState, bottomSheetState, snackbarHostState) { + BottomSheetScaffoldState( + drawerState = drawerState, + bottomSheetState = bottomSheetState, + snackbarHostState = snackbarHostState + ) + } +} + +/** + * Material Design standard bottom sheet. + * + * Standard bottom sheets co-exist with the screen’s main UI region and allow for simultaneously + * viewing and interacting with both regions. They are commonly used to keep a feature or + * secondary content visible on screen when content in main UI region is frequently scrolled or + * panned. + * + * ![Standard bottom sheet image](https://developer.android.com/images/reference/androidx/compose/material/standard-bottom-sheet.png) + * + * This component provides an API to put together several material components to construct your + * screen. For a similar component which implements the basic material design layout strategy + * with app bars, floating action buttons and navigation drawers, use the standard [Scaffold]. + * For similar component that uses a backdrop as the centerpiece of the screen, use + * [BackdropScaffold]. + * + * A simple example of a bottom sheet scaffold looks like this: + * + * @sample androidx.compose.material.samples.BottomSheetScaffoldSample + * + * @param sheetContent The content of the bottom sheet. + * @param modifier An optional [Modifier] for the root of the scaffold. + * @param scaffoldState The state of the scaffold. + * @param topBar An optional top app bar. + * @param snackbarHost The composable hosting the snackbars shown inside the scaffold. + * @param floatingActionButton An optional floating action button. + * @param floatingActionButtonPosition The position of the floating action button. + * @param sheetGesturesEnabled Whether the bottom sheet can be interacted with by gestures. + * @param sheetShape The shape of the bottom sheet. + * @param sheetElevation The elevation of the bottom sheet. + * @param sheetBackgroundColor The background color of the bottom sheet. + * @param sheetContentColor The preferred content color provided by the bottom sheet to its + * children. Defaults to the matching content color for [sheetBackgroundColor], or if that is + * not a color from the theme, this will keep the same content color set above the bottom sheet. + * @param sheetPeekHeight The height of the bottom sheet when it is collapsed. + * @param drawerContent The content of the drawer sheet. + * @param drawerGesturesEnabled Whether the drawer sheet can be interacted with by gestures. + * @param drawerShape The shape of the drawer sheet. + * @param drawerElevation The elevation of the drawer sheet. + * @param drawerBackgroundColor The background color of the drawer sheet. + * @param drawerContentColor The preferred content color provided by the drawer sheet to its + * children. Defaults to the matching content color for [drawerBackgroundColor], or if that is + * not a color from the theme, this will keep the same content color set above the drawer sheet. + * @param drawerScrimColor The color of the scrim that is applied when the drawer is open. + * @param content The main content of the screen. You should use the provided [PaddingValues] + * to properly offset the content, so that it is not obstructed by the bottom sheet when collapsed. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BottomSheetScaffold( + sheetContent: @Composable ColumnScope.() -> Unit, + modifier: Modifier = Modifier, + scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(), + topBar: (@Composable () -> Unit)? = null, + snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) }, + floatingActionButton: (@Composable () -> Unit)? = null, + floatingActionButtonPosition: FabPosition = FabPosition.End, + sheetGesturesEnabled: Boolean = true, + sheetShape: Shape = MaterialTheme.shapes.large, + sheetElevation: Dp = BottomSheetScaffoldDefaults.SheetElevation, + sheetBackgroundColor: Color = MaterialTheme.colorScheme.surface, + sheetContentColor: Color = contentColorFor(sheetBackgroundColor), + sheetPeekHeight: Dp = BottomSheetScaffoldDefaults.SheetPeekHeight, + drawerContent: @Composable (ColumnScope.() -> Unit)? = null, + drawerGesturesEnabled: Boolean = true, + drawerShape: Shape = MaterialTheme.shapes.large, + drawerElevation: Dp = DrawerDefaults.Elevation, + drawerBackgroundColor: Color = MaterialTheme.colorScheme.surface, + drawerContentColor: Color = contentColorFor(drawerBackgroundColor), + drawerScrimColor: Color = DrawerDefaults.scrimColor, + backgroundColor: Color = MaterialTheme.colorScheme.background, + contentColor: Color = contentColorFor(backgroundColor), + content: @Composable (PaddingValues) -> Unit +) { + val scope = rememberCoroutineScope() + BoxWithConstraints(modifier) { + val fullHeight = constraints.maxHeight.toFloat() + val peekHeightPx = with(LocalDensity.current) { sheetPeekHeight.toPx() } + var bottomSheetHeight by remember { mutableStateOf(fullHeight) } + + val swipeable = Modifier + .nestedScroll(scaffoldState.bottomSheetState.nestedScrollConnection) + .swipeable( + state = scaffoldState.bottomSheetState, + anchors = mapOf( + fullHeight - peekHeightPx to BottomSheetValue.Collapsed, + fullHeight - bottomSheetHeight to BottomSheetValue.Expanded + ), + orientation = Orientation.Vertical, + enabled = sheetGesturesEnabled, + resistance = null + ) + .semantics { + if (peekHeightPx != bottomSheetHeight) { + if (scaffoldState.bottomSheetState.isCollapsed) { + expand { + if (scaffoldState.bottomSheetState.confirmStateChange(BottomSheetValue.Expanded)) { + scope.launch { scaffoldState.bottomSheetState.expand() } + } + true + } + } else { + collapse { + if (scaffoldState.bottomSheetState.confirmStateChange(BottomSheetValue.Collapsed)) { + scope.launch { scaffoldState.bottomSheetState.collapse() } + } + true + } + } + } + } + + val child = @Composable { + BottomSheetScaffoldStack( + body = { + Surface( + color = backgroundColor, + contentColor = contentColor + ) { + Column(Modifier.fillMaxSize()) { + topBar?.invoke() + content(PaddingValues(bottom = sheetPeekHeight)) + } + } + }, + bottomSheet = { + Surface( + swipeable + .fillMaxWidth() + .requiredHeightIn(min = sheetPeekHeight) + .onGloballyPositioned { + bottomSheetHeight = it.size.height.toFloat() + }, + shape = sheetShape, + // parameter does not exists in material3 + // elevation = sheetElevation, + color = sheetBackgroundColor, + contentColor = sheetContentColor, + content = { Column(content = sheetContent) } + ) + }, + floatingActionButton = { + Box { + floatingActionButton?.invoke() + } + }, + snackbarHost = { + Box { + snackbarHost(scaffoldState.snackbarHostState) + } + }, + bottomSheetOffset = scaffoldState.bottomSheetState.offset, + floatingActionButtonPosition = floatingActionButtonPosition + ) + } + if (drawerContent == null) { + child() + } else { + ModalDrawer( + drawerContent = drawerContent, + drawerState = scaffoldState.drawerState, + gesturesEnabled = drawerGesturesEnabled, + drawerShape = drawerShape, + drawerElevation = drawerElevation, + drawerBackgroundColor = drawerBackgroundColor, + drawerContentColor = drawerContentColor, + scrimColor = drawerScrimColor, + content = child + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun BottomSheetScaffoldStack( + body: @Composable () -> Unit, + bottomSheet: @Composable () -> Unit, + floatingActionButton: @Composable () -> Unit, + snackbarHost: @Composable () -> Unit, + bottomSheetOffset: State, + floatingActionButtonPosition: FabPosition +) { + Layout( + content = { + body() + bottomSheet() + floatingActionButton() + snackbarHost() + } + ) { measurables, constraints -> + val placeable = measurables.first().measure(constraints) + + layout(placeable.width, placeable.height) { + placeable.placeRelative(0, 0) + + val (sheetPlaceable, fabPlaceable, snackbarPlaceable) = + measurables.drop(1).map { + it.measure(constraints.copy(minWidth = 0, minHeight = 0)) + } + + val sheetOffsetY = bottomSheetOffset.value.roundToInt() + + sheetPlaceable.placeRelative(0, sheetOffsetY) + + val fabOffsetX = when (floatingActionButtonPosition) { + FabPosition.Center -> (placeable.width - fabPlaceable.width) / 2 + else -> placeable.width - fabPlaceable.width - FabEndSpacing.roundToPx() + } + val fabOffsetY = sheetOffsetY - fabPlaceable.height / 2 + + fabPlaceable.placeRelative(fabOffsetX, fabOffsetY) + + val snackbarOffsetX = (placeable.width - snackbarPlaceable.width) / 2 + val snackbarOffsetY = placeable.height - snackbarPlaceable.height + + snackbarPlaceable.placeRelative(snackbarOffsetX, snackbarOffsetY) + } + } +} + +private val FabEndSpacing = 16.dp + +/** + * Contains useful defaults for [BottomSheetScaffold]. + */ +object BottomSheetScaffoldDefaults { + + /** + * The default elevation used by [BottomSheetScaffold]. + */ + val SheetElevation = 8.dp + + /** + * The default peek height used by [BottomSheetScaffold]. + */ + val SheetPeekHeight = 56.dp +} \ No newline at end of file diff --git a/presentation/src/main/java/com/android254/presentation/common/bottomsheet/Drawer.kt b/presentation/src/main/java/com/android254/presentation/common/bottomsheet/Drawer.kt new file mode 100644 index 00000000..b1806bf7 --- /dev/null +++ b/presentation/src/main/java/com/android254/presentation/common/bottomsheet/Drawer.kt @@ -0,0 +1,685 @@ +/* + * Copyright 2023 DroidconKE + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android254.presentation.common.bottomsheet + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.TweenSpec +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.dismiss +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.paneTitle +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch +import kotlin.math.max +import kotlin.math.roundToInt +/** + * Possible values of [DrawerState]. + */ +enum class DrawerValue { + /** + * The state of the drawer when it is closed. + */ + Closed, + + /** + * The state of the drawer when it is open. + */ + Open +} + +/** + * Possible values of [BottomDrawerState]. + */ +enum class BottomDrawerValue { + /** + * The state of the bottom drawer when it is closed. + */ + Closed, + + /** + * The state of the bottom drawer when it is open (i.e. at 50% height). + */ + Open, + + /** + * The state of the bottom drawer when it is expanded (i.e. at 100% height). + */ + Expanded +} + +/** + * State of the [ModalDrawer] composable. + * + * @param initialValue The initial value of the state. + * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Suppress("NotCloseable") +@Stable +class DrawerState( + initialValue: DrawerValue, + confirmStateChange: (DrawerValue) -> Boolean = { true } +) { + + internal val swipeableState = SwipeableState( + initialValue = initialValue, + animationSpec = AnimationSpec, + confirmStateChange = confirmStateChange + ) + + /** + * Whether the drawer is open. + */ + val isOpen: Boolean + get() = currentValue == DrawerValue.Open + + /** + * Whether the drawer is closed. + */ + val isClosed: Boolean + get() = currentValue == DrawerValue.Closed + + /** + * The current value of the state. + * + * If no swipe or animation is in progress, this corresponds to the start the drawer + * currently in. If a swipe or an animation is in progress, this corresponds the state drawer + * was in before the swipe or animation started. + */ + val currentValue: DrawerValue + get() { + return swipeableState.currentValue + } + + /** + * Whether the state is currently animating. + */ + val isAnimationRunning: Boolean + get() { + return swipeableState.isAnimationRunning + } + + /** + * Open the drawer with animation and suspend until it if fully opened or animation has been + * cancelled. This method will throw [CancellationException] if the animation is + * interrupted + * + * @return the reason the open animation ended + */ + suspend fun open() = animateTo(DrawerValue.Open, AnimationSpec) + + /** + * Close the drawer with animation and suspend until it if fully closed or animation has been + * cancelled. This method will throw [CancellationException] if the animation is + * interrupted + * + * @return the reason the close animation ended + */ + suspend fun close() = animateTo(DrawerValue.Closed, AnimationSpec) + + /** + * Set the state of the drawer with specific animation + * + * @param targetValue The new value to animate to. + * @param anim The animation that will be used to animate to the new value. + */ + suspend fun animateTo(targetValue: DrawerValue, anim: AnimationSpec) { + swipeableState.animateTo(targetValue, anim) + } + + /** + * Set the state without any animation and suspend until it's set + * + * @param targetValue The new target value + */ + suspend fun snapTo(targetValue: DrawerValue) { + swipeableState.snapTo(targetValue) + } + + /** + * The target value of the drawer state. + * + * If a swipe is in progress, this is the value that the Drawer would animate to if the + * swipe finishes. If an animation is running, this is the target value of that animation. + * Finally, if no swipe or animation is in progress, this is the same as the [currentValue]. + */ + @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET") + val targetValue: DrawerValue + get() = swipeableState.targetValue + + /** + * The current position (in pixels) of the drawer sheet. + */ + @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET") + val offset: State + get() = swipeableState.offset + + companion object { + /** + * The default [Saver] implementation for [DrawerState]. + */ + fun Saver(confirmStateChange: (DrawerValue) -> Boolean) = + Saver( + save = { it.currentValue }, + restore = { DrawerState(it, confirmStateChange) } + ) + } +} + +/** + * State of the [BottomDrawer] composable. + * + * @param initialValue The initial value of the state. + * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Suppress("NotCloseable") +class BottomDrawerState( + initialValue: BottomDrawerValue, + confirmStateChange: (BottomDrawerValue) -> Boolean = { true } +) : SwipeableState( + initialValue = initialValue, + animationSpec = AnimationSpec, + confirmStateChange = confirmStateChange +) { + /** + * Whether the drawer is open, either in opened or expanded state. + */ + val isOpen: Boolean + get() = currentValue != BottomDrawerValue.Closed + + /** + * Whether the drawer is closed. + */ + val isClosed: Boolean + get() = currentValue == BottomDrawerValue.Closed + + /** + * Whether the drawer is expanded. + */ + val isExpanded: Boolean + get() = currentValue == BottomDrawerValue.Expanded + + /** + * Open the drawer with animation and suspend until it if fully opened or animation has been + * cancelled. If the content height is less than [BottomDrawerOpenFraction], the drawer state + * will move to [BottomDrawerValue.Expanded] instead. + * + * @throws [CancellationException] if the animation is interrupted + * + */ + suspend fun open() { + val targetValue = + if (isOpenEnabled) BottomDrawerValue.Open else BottomDrawerValue.Expanded + animateTo(targetValue) + } + + /** + * Close the drawer with animation and suspend until it if fully closed or animation has been + * cancelled. + * + * @throws [CancellationException] if the animation is interrupted + * + */ + suspend fun close() = animateTo(BottomDrawerValue.Closed) + + /** + * Expand the drawer with animation and suspend until it if fully expanded or animation has + * been cancelled. + * + * @throws [CancellationException] if the animation is interrupted + * + */ + suspend fun expand() = animateTo(BottomDrawerValue.Expanded) + + private val isOpenEnabled: Boolean + get() = anchors.values.contains(BottomDrawerValue.Open) + + internal val nestedScrollConnection = this.PreUpPostDownNestedScrollConnection + + companion object { + /** + * The default [Saver] implementation for [BottomDrawerState]. + */ + fun Saver(confirmStateChange: (BottomDrawerValue) -> Boolean) = + Saver( + save = { it.currentValue }, + restore = { BottomDrawerState(it, confirmStateChange) } + ) + } +} + +/** + * Create and [remember] a [DrawerState]. + * + * @param initialValue The initial value of the state. + * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change. + */ +@Composable +fun rememberDrawerState( + initialValue: DrawerValue, + confirmStateChange: (DrawerValue) -> Boolean = { true } +): DrawerState { + return rememberSaveable(saver = DrawerState.Saver(confirmStateChange)) { + DrawerState(initialValue, confirmStateChange) + } +} + +/** + * Create and [remember] a [BottomDrawerState]. + * + * @param initialValue The initial value of the state. + * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change. + */ +@Composable +fun rememberBottomDrawerState( + initialValue: BottomDrawerValue, + confirmStateChange: (BottomDrawerValue) -> Boolean = { true } +): BottomDrawerState { + return rememberSaveable(saver = BottomDrawerState.Saver(confirmStateChange)) { + BottomDrawerState(initialValue, confirmStateChange) + } +} + +/** + * Material Design modal navigation drawer. + * + * Modal navigation drawers block interaction with the rest of an app’s content with a scrim. + * They are elevated above most of the app’s UI and don’t affect the screen’s layout grid. + * + * ![Modal drawer image](https://developer.android.com/images/reference/androidx/compose/material/modal-drawer.png) + * + * See [BottomDrawer] for a layout that introduces a bottom drawer, suitable when + * using bottom navigation. + * + * @sample androidx.compose.material.samples.ModalDrawerSample + * + * @param drawerContent composable that represents content inside the drawer + * @param modifier optional modifier for the drawer + * @param drawerState state of the drawer + * @param gesturesEnabled whether or not drawer can be interacted by gestures + * @param drawerShape shape of the drawer sheet + * @param drawerElevation drawer sheet elevation. This controls the size of the shadow below the + * drawer sheet + * @param drawerBackgroundColor background color to be used for the drawer sheet + * @param drawerContentColor color of the content to use inside the drawer sheet. Defaults to + * either the matching content color for [drawerBackgroundColor], or, if it is not a color from + * the theme, this will keep the same value set above this Surface. + * @param scrimColor color of the scrim that obscures content when the drawer is open + * @param content content of the rest of the UI + * + * @throws IllegalStateException when parent has [Float.POSITIVE_INFINITY] width + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ModalDrawer( + drawerContent: @Composable ColumnScope.() -> Unit, + modifier: Modifier = Modifier, + drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed), + gesturesEnabled: Boolean = true, + drawerShape: Shape = MaterialTheme.shapes.large, + drawerElevation: Dp = DrawerDefaults.Elevation, + drawerBackgroundColor: Color = MaterialTheme.colorScheme.surface, + drawerContentColor: Color = contentColorFor(drawerBackgroundColor), + scrimColor: Color = DrawerDefaults.scrimColor, + content: @Composable () -> Unit +) { + val scope = rememberCoroutineScope() + BoxWithConstraints(modifier.fillMaxSize()) { + val modalDrawerConstraints = constraints + // TODO : think about Infinite max bounds case + if (!modalDrawerConstraints.hasBoundedWidth) { + throw IllegalStateException("Drawer shouldn't have infinite width") + } + + val minValue = -modalDrawerConstraints.maxWidth.toFloat() + val maxValue = 0f + + val anchors = mapOf(minValue to DrawerValue.Closed, maxValue to DrawerValue.Open) + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl + Box( + Modifier.swipeable( + state = drawerState.swipeableState, + anchors = anchors, + thresholds = { _, _ -> FractionalThreshold(0.5f) }, + orientation = Orientation.Horizontal, + enabled = gesturesEnabled, + reverseDirection = isRtl, + velocityThreshold = DrawerVelocityThreshold, + resistance = null + ) + ) { + Box { + content() + } + Scrim( + open = drawerState.isOpen, + onClose = { + if ( + gesturesEnabled && + drawerState.swipeableState.confirmStateChange(DrawerValue.Closed) + ) { + scope.launch { drawerState.close() } + } + }, + fraction = { + calculateFraction(minValue, maxValue, drawerState.offset.value) + }, + color = scrimColor + ) + val navigationMenu = getString(Strings.NavigationMenu) + Surface( + modifier = with(LocalDensity.current) { + Modifier + .sizeIn( + minWidth = modalDrawerConstraints.minWidth.toDp(), + minHeight = modalDrawerConstraints.minHeight.toDp(), + maxWidth = modalDrawerConstraints.maxWidth.toDp(), + maxHeight = modalDrawerConstraints.maxHeight.toDp() + ) + } + .offset { IntOffset(drawerState.offset.value.roundToInt(), 0) } + .padding(end = EndDrawerPadding) + .semantics { + paneTitle = navigationMenu + if (drawerState.isOpen) { + dismiss { + if ( + drawerState.swipeableState + .confirmStateChange(DrawerValue.Closed) + ) { + scope.launch { drawerState.close() } + }; true + } + } + }, + shape = drawerShape, + color = drawerBackgroundColor, + contentColor = drawerContentColor + // does not exist in material3 + // elevation = drawerElevation + ) { + Column(Modifier.fillMaxSize(), content = drawerContent) + } + } + } +} + +/** + * Material Design bottom navigation drawer. + * + * Bottom navigation drawers are modal drawers that are anchored to the bottom of the screen instead + * of the left or right edge. They are only used with bottom app bars. + * + * ![Bottom drawer image](https://developer.android.com/images/reference/androidx/compose/material/bottom-drawer.png) + * + * See [ModalDrawer] for a layout that introduces a classic from-the-side drawer. + * + * @sample androidx.compose.material.samples.BottomDrawerSample + * + * @param drawerState state of the drawer + * @param modifier optional [Modifier] for the entire component + * @param gesturesEnabled whether or not drawer can be interacted by gestures + * @param drawerShape shape of the drawer sheet + * @param drawerElevation drawer sheet elevation. This controls the size of the shadow below the + * drawer sheet + * @param drawerContent composable that represents content inside the drawer + * @param drawerBackgroundColor background color to be used for the drawer sheet + * @param drawerContentColor color of the content to use inside the drawer sheet. Defaults to + * either the matching content color for [drawerBackgroundColor], or, if it is not a color from + * the theme, this will keep the same value set above this Surface. + * @param scrimColor color of the scrim that obscures content when the drawer is open. If the + * color passed is [Color.Unspecified], then a scrim will no longer be applied and the bottom + * drawer will not block interaction with the rest of the screen when visible. + * @param content content of the rest of the UI + * + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BottomDrawer( + drawerContent: @Composable ColumnScope.() -> Unit, + modifier: Modifier = Modifier, + drawerState: BottomDrawerState = rememberBottomDrawerState(BottomDrawerValue.Closed), + gesturesEnabled: Boolean = true, + drawerShape: Shape = MaterialTheme.shapes.large, + drawerElevation: Dp = DrawerDefaults.Elevation, + drawerBackgroundColor: Color = MaterialTheme.colorScheme.surface, + drawerContentColor: Color = contentColorFor(drawerBackgroundColor), + scrimColor: Color = DrawerDefaults.scrimColor, + content: @Composable () -> Unit +) { + val scope = rememberCoroutineScope() + + BoxWithConstraints(modifier.fillMaxSize()) { + val fullHeight = constraints.maxHeight.toFloat() + var drawerHeight by remember(fullHeight) { mutableStateOf(fullHeight) } + // TODO(b/178630869) Proper landscape support + val isLandscape = constraints.maxWidth > constraints.maxHeight + + val minHeight = 0f + val peekHeight = fullHeight * BottomDrawerOpenFraction + val expandedHeight = max(minHeight, fullHeight - drawerHeight) + val anchors = if (drawerHeight < peekHeight || isLandscape) { + mapOf( + fullHeight to BottomDrawerValue.Closed, + expandedHeight to BottomDrawerValue.Expanded + ) + } else { + mapOf( + fullHeight to BottomDrawerValue.Closed, + peekHeight to BottomDrawerValue.Open, + expandedHeight to BottomDrawerValue.Expanded + ) + } + val drawerConstraints = with(LocalDensity.current) { + Modifier + .sizeIn( + maxWidth = constraints.maxWidth.toDp(), + maxHeight = constraints.maxHeight.toDp() + ) + } + val nestedScroll = if (gesturesEnabled) { + Modifier.nestedScroll(drawerState.nestedScrollConnection) + } else { + Modifier + } + val swipeable = Modifier + .then(nestedScroll) + .swipeable( + state = drawerState, + anchors = anchors, + orientation = Orientation.Vertical, + enabled = gesturesEnabled, + resistance = null + ) + + Box(swipeable) { + content() + BottomDrawerScrim( + color = scrimColor, + onDismiss = { + if ( + gesturesEnabled && drawerState.confirmStateChange(BottomDrawerValue.Closed) + ) { + scope.launch { drawerState.close() } + } + }, + visible = drawerState.targetValue != BottomDrawerValue.Closed + ) + val navigationMenu = getString(Strings.NavigationMenu) + Surface( + drawerConstraints + .offset { IntOffset(x = 0, y = drawerState.offset.value.roundToInt()) } + .onGloballyPositioned { position -> + drawerHeight = position.size.height.toFloat() + } + .semantics { + paneTitle = navigationMenu + if (drawerState.isOpen) { + // TODO(b/180101663) The action currently doesn't return the correct results + dismiss { + if (drawerState.confirmStateChange(BottomDrawerValue.Closed)) { + scope.launch { drawerState.close() } + }; true + } + } + }, + shape = drawerShape, + color = drawerBackgroundColor, + contentColor = drawerContentColor + // parameter does not exist in material3 + // elevation = drawerElevation + ) { + Column(content = drawerContent) + } + } + } +} + +/** + * Object to hold default values for [ModalDrawer] and [BottomDrawer] + */ +object DrawerDefaults { + + /** + * Default Elevation for drawer sheet as specified in material specs + */ + val Elevation = 16.dp + + val scrimColor: Color + @Composable + get() = MaterialTheme.colorScheme.onSurface.copy(alpha = ScrimOpacity) + + /** + * Default alpha for scrim color + */ + const val ScrimOpacity = 0.32f +} + +private fun calculateFraction(a: Float, b: Float, pos: Float) = + ((pos - a) / (b - a)).coerceIn(0f, 1f) + +@Composable +private fun BottomDrawerScrim( + color: Color, + onDismiss: () -> Unit, + visible: Boolean +) { + if (color.isSpecified) { + val alpha by animateFloatAsState( + targetValue = if (visible) 1f else 0f, + animationSpec = TweenSpec() + ) + val closeDrawer = getString(Strings.CloseDrawer) + val dismissModifier = if (visible) { + Modifier + .pointerInput(onDismiss) { + detectTapGestures { onDismiss() } + } + .semantics(mergeDescendants = true) { + contentDescription = closeDrawer + onClick { onDismiss(); true } + } + } else { + Modifier + } + + Canvas( + Modifier + .fillMaxSize() + .then(dismissModifier) + ) { + drawRect(color = color, alpha = alpha) + } + } +} + +@Composable +private fun Scrim( + open: Boolean, + onClose: () -> Unit, + fraction: () -> Float, + color: Color +) { + val closeDrawer = getString(Strings.CloseDrawer) + val dismissDrawer = if (open) { + Modifier + .pointerInput(onClose) { detectTapGestures { onClose() } } + .semantics(mergeDescendants = true) { + contentDescription = closeDrawer + onClick { onClose(); true } + } + } else { + Modifier + } + + Canvas( + Modifier + .fillMaxSize() + .then(dismissDrawer) + ) { + drawRect(color, alpha = fraction()) + } +} + +private val EndDrawerPadding = 56.dp +private val DrawerVelocityThreshold = 400.dp + +// TODO: b/177571613 this should be a proper decay settling +// this is taken from the DrawerLayout's DragViewHelper as a min duration. +private val AnimationSpec = TweenSpec(durationMillis = 256) + +private const val BottomDrawerOpenFraction = 0.5f \ No newline at end of file diff --git a/presentation/src/main/java/com/android254/presentation/common/bottomsheet/Strings.kt b/presentation/src/main/java/com/android254/presentation/common/bottomsheet/Strings.kt new file mode 100644 index 00000000..103dcbdf --- /dev/null +++ b/presentation/src/main/java/com/android254/presentation/common/bottomsheet/Strings.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2023 DroidconKE + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android254.presentation.common.bottomsheet + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.R +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext + +@Immutable +@kotlin.jvm.JvmInline +value class Strings private constructor(@Suppress("unused") private val value: Int) { + companion object { + val NavigationMenu = Strings(0) + val CloseDrawer = Strings(1) + val CloseSheet = Strings(2) + val DefaultErrorMessage = Strings(3) + val ExposedDropdownMenu = Strings(4) + val SliderRangeStart = Strings(5) + val SliderRangeEnd = Strings(6) + } +} + +@Composable +fun getString(string: Strings): String { + LocalConfiguration.current + val resources = LocalContext.current.resources + return when (string) { + Strings.NavigationMenu -> resources.getString(R.string.navigation_menu) + Strings.CloseDrawer -> resources.getString(R.string.close_drawer) + Strings.CloseSheet -> resources.getString(R.string.close_sheet) + Strings.DefaultErrorMessage -> resources.getString(R.string.default_error_message) + Strings.ExposedDropdownMenu -> resources.getString(R.string.dropdown_menu) + Strings.SliderRangeStart -> resources.getString(R.string.range_start) + Strings.SliderRangeEnd -> resources.getString(R.string.range_end) + else -> "" + } +} \ No newline at end of file diff --git a/presentation/src/main/java/com/android254/presentation/common/bottomsheet/Swipeable.kt b/presentation/src/main/java/com/android254/presentation/common/bottomsheet/Swipeable.kt new file mode 100644 index 00000000..e125107a --- /dev/null +++ b/presentation/src/main/java/com/android254/presentation/common/bottomsheet/Swipeable.kt @@ -0,0 +1,886 @@ +/* + * Copyright 2023 DroidconKE + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android254.presentation.common.bottomsheet + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.SpringSpec +import androidx.compose.foundation.gestures.DraggableState +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import com.android254.presentation.common.bottomsheet.SwipeableDefaults.AnimationSpec +import com.android254.presentation.common.bottomsheet.SwipeableDefaults.StandardResistanceFactor +import com.android254.presentation.common.bottomsheet.SwipeableDefaults.VelocityThreshold +import com.android254.presentation.common.bottomsheet.SwipeableDefaults.resistanceConfig +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.launch +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.sign +import kotlin.math.sin + +/** + * State of the [swipeable] modifier. + * + * This contains necessary information about any ongoing swipe or animation and provides methods + * to change the state either immediately or by starting an animation. To create and remember a + * [SwipeableState] with the default animation clock, use [rememberSwipeableState]. + * + * @param initialValue The initial value of the state. + * @param animationSpec The default animation that will be used to animate to a new state. + * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change. + */ +@Stable +@ExperimentalMaterial3Api +open class SwipeableState( + initialValue: T, + internal val animationSpec: AnimationSpec = AnimationSpec, + internal val confirmStateChange: (newValue: T) -> Boolean = { true } +) { + /** + * The current value of the state. + * + * If no swipe or animation is in progress, this corresponds to the anchor at which the + * [swipeable] is currently settled. If a swipe or animation is in progress, this corresponds + * the last anchor at which the [swipeable] was settled before the swipe or animation started. + */ + var currentValue: T by mutableStateOf(initialValue) + private set + + /** + * Whether the state is currently animating. + */ + var isAnimationRunning: Boolean by mutableStateOf(false) + private set + + /** + * The current position (in pixels) of the [swipeable]. + * + * You should use this state to offset your content accordingly. The recommended way is to + * use `Modifier.offsetPx`. This includes the resistance by default, if resistance is enabled. + */ + val offset: State get() = offsetState + + /** + * The amount by which the [swipeable] has been swiped past its bounds. + */ + val overflow: State get() = overflowState + + // Use `Float.NaN` as a placeholder while the state is uninitialised. + private val offsetState = mutableStateOf(0f) + private val overflowState = mutableStateOf(0f) + + // the source of truth for the "real"(non ui) position + // basically position in bounds + overflow + private val absoluteOffset = mutableStateOf(0f) + + // current animation target, if animating, otherwise null + private val animationTarget = mutableStateOf(null) + + internal var anchors by mutableStateOf(emptyMap()) + + private val latestNonEmptyAnchorsFlow: Flow> = + snapshotFlow { anchors } + .filter { it.isNotEmpty() } + .take(1) + + internal var minBound = Float.NEGATIVE_INFINITY + internal var maxBound = Float.POSITIVE_INFINITY + + internal fun ensureInit(newAnchors: Map) { + if (anchors.isEmpty()) { + // need to do initial synchronization synchronously :( + val initialOffset = newAnchors.getOffset(currentValue) + requireNotNull(initialOffset) { + "The initial value must have an associated anchor." + } + offsetState.value = initialOffset + absoluteOffset.value = initialOffset + } + } + + internal suspend fun processNewAnchors( + oldAnchors: Map, + newAnchors: Map + ) { + if (oldAnchors.isEmpty()) { + // If this is the first time that we receive anchors, then we need to initialise + // the state so we snap to the offset associated to the initial value. + minBound = newAnchors.keys.minOrNull()!! + maxBound = newAnchors.keys.maxOrNull()!! + val initialOffset = newAnchors.getOffset(currentValue) + requireNotNull(initialOffset) { + "The initial value must have an associated anchor." + } + snapInternalToOffset(initialOffset) + } else if (newAnchors != oldAnchors) { + // If we have received new anchors, then the offset of the current value might + // have changed, so we need to animate to the new offset. If the current value + // has been removed from the anchors then we animate to the closest anchor + // instead. Note that this stops any ongoing animation. + minBound = Float.NEGATIVE_INFINITY + maxBound = Float.POSITIVE_INFINITY + val animationTargetValue = animationTarget.value + // if we're in the animation already, let's find it a new home + val targetOffset = if (animationTargetValue != null) { + // first, try to map old state to the new state + val oldState = oldAnchors[animationTargetValue] + val newState = newAnchors.getOffset(oldState) + // return new state if exists, or find the closes one among new anchors + newState ?: newAnchors.keys.minByOrNull { abs(it - animationTargetValue) }!! + } else { + // we're not animating, proceed by finding the new anchors for an old value + val actualOldValue = oldAnchors[offset.value] + val value = if (actualOldValue == currentValue) currentValue else actualOldValue + newAnchors.getOffset(value) ?: newAnchors + .keys.minByOrNull { abs(it - offset.value) }!! + } + try { + animateInternalToOffset(targetOffset, animationSpec) + } catch (c: CancellationException) { + // If the animation was interrupted for any reason, snap as a last resort. + snapInternalToOffset(targetOffset) + } finally { + currentValue = newAnchors.getValue(targetOffset) + minBound = newAnchors.keys.minOrNull()!! + maxBound = newAnchors.keys.maxOrNull()!! + } + } + } + + internal var thresholds: (Float, Float) -> Float by mutableStateOf({ _, _ -> 0f }) + + internal var velocityThreshold by mutableStateOf(0f) + + internal var resistance: ResistanceConfig? by mutableStateOf(null) + + internal val draggableState = DraggableState { + val newAbsolute = absoluteOffset.value + it + val clamped = newAbsolute.coerceIn(minBound, maxBound) + val overflow = newAbsolute - clamped + val resistanceDelta = resistance?.computeResistance(overflow) ?: 0f + offsetState.value = clamped + resistanceDelta + overflowState.value = overflow + absoluteOffset.value = newAbsolute + } + + private suspend fun snapInternalToOffset(target: Float) { + draggableState.drag { + dragBy(target - absoluteOffset.value) + } + } + + private suspend fun animateInternalToOffset(target: Float, spec: AnimationSpec) { + draggableState.drag { + var prevValue = absoluteOffset.value + animationTarget.value = target + isAnimationRunning = true + try { + Animatable(prevValue).animateTo(target, spec) { + dragBy(this.value - prevValue) + prevValue = this.value + } + } finally { + animationTarget.value = null + isAnimationRunning = false + } + } + } + + /** + * The target value of the state. + * + * If a swipe is in progress, this is the value that the [swipeable] would animate to if the + * swipe finished. If an animation is running, this is the target value of that animation. + * Finally, if no swipe or animation is in progress, this is the same as the [currentValue]. + */ + @ExperimentalMaterial3Api + internal val targetValue: T + get() { + // TODO(calintat): Track current velocity (b/149549482) and use that here. + val target = animationTarget.value ?: computeTarget( + offset = offset.value, + lastValue = anchors.getOffset(currentValue) ?: offset.value, + anchors = anchors.keys, + thresholds = thresholds, + velocity = 0f, + velocityThreshold = Float.POSITIVE_INFINITY + ) + return anchors[target] ?: currentValue + } + + /** + * Information about the ongoing swipe or animation, if any. See [SwipeProgress] for details. + * + * If no swipe or animation is in progress, this returns `SwipeProgress(value, value, 1f)`. + */ + @ExperimentalMaterial3Api + internal val progress: SwipeProgress + get() { + val bounds = findBounds(offset.value, anchors.keys) + val from: T + val to: T + val fraction: Float + when (bounds.size) { + 0 -> { + from = currentValue + to = currentValue + fraction = 1f + } + 1 -> { + from = anchors.getValue(bounds[0]) + to = anchors.getValue(bounds[0]) + fraction = 1f + } + else -> { + val (a, b) = + if (direction > 0f) { + bounds[0] to bounds[1] + } else { + bounds[1] to bounds[0] + } + from = anchors.getValue(a) + to = anchors.getValue(b) + fraction = (offset.value - a) / (b - a) + } + } + return SwipeProgress(from, to, fraction) + } + + /** + * The direction in which the [swipeable] is moving, relative to the current [currentValue]. + * + * This will be either 1f if it is is moving from left to right or top to bottom, -1f if it is + * moving from right to left or bottom to top, or 0f if no swipe or animation is in progress. + */ + @ExperimentalMaterial3Api + internal val direction: Float + get() = anchors.getOffset(currentValue)?.let { sign(offset.value - it) } ?: 0f + + /** + * Set the state without any animation and suspend until it's set + * + * @param targetValue The new target value to set [currentValue] to. + */ + @ExperimentalMaterial3Api + internal suspend fun snapTo(targetValue: T) { + latestNonEmptyAnchorsFlow.collect { anchors -> + val targetOffset = anchors.getOffset(targetValue) + requireNotNull(targetOffset) { + "The target value must have an associated anchor." + } + snapInternalToOffset(targetOffset) + currentValue = targetValue + } + } + + /** + * Set the state to the target value by starting an animation. + * + * @param targetValue The new value to animate to. + * @param anim The animation that will be used to animate to the new value. + */ + @ExperimentalMaterial3Api + internal suspend fun animateTo(targetValue: T, anim: AnimationSpec = animationSpec) { + latestNonEmptyAnchorsFlow.collect { anchors -> + try { + val targetOffset = anchors.getOffset(targetValue) + requireNotNull(targetOffset) { + "The target value must have an associated anchor." + } + animateInternalToOffset(targetOffset, anim) + } finally { + val endOffset = absoluteOffset.value + val endValue = anchors + // fighting rounding error once again, anchor should be as close as 0.5 pixels + .filterKeys { anchorOffset -> abs(anchorOffset - endOffset) < 0.5f } + .values.firstOrNull() ?: currentValue + currentValue = endValue + } + } + } + + /** + * Perform fling with settling to one of the anchors which is determined by the given + * [velocity]. Fling with settling [swipeable] will always consume all the velocity provided + * since it will settle at the anchor. + * + * In general cases, [swipeable] flings by itself when being swiped. This method is to be + * used for nested scroll logic that wraps the [swipeable]. In nested scroll developer may + * want to trigger settling fling when the child scroll container reaches the bound. + * + * @param velocity velocity to fling and settle with + * + * @return the reason fling ended + */ + internal suspend fun performFling(velocity: Float) { + latestNonEmptyAnchorsFlow.collect { anchors -> + val lastAnchor = anchors.getOffset(currentValue)!! + val targetValue = computeTarget( + offset = offset.value, + lastValue = lastAnchor, + anchors = anchors.keys, + thresholds = thresholds, + velocity = velocity, + velocityThreshold = velocityThreshold + ) + val targetState = anchors[targetValue] + if (targetState != null && confirmStateChange(targetState)) { + animateTo(targetState) + } // If the user vetoed the state change, rollback to the previous state. + else { + animateInternalToOffset(lastAnchor, animationSpec) + } + } + } + + /** + * Force [swipeable] to consume drag delta provided from outside of the regular [swipeable] + * gesture flow. + * + * Note: This method performs generic drag and it won't settle to any particular anchor, * + * leaving swipeable in between anchors. When done dragging, [performFling] must be + * called as well to ensure swipeable will settle at the anchor. + * + * In general cases, [swipeable] drags by itself when being swiped. This method is to be + * used for nested scroll logic that wraps the [swipeable]. In nested scroll developer may + * want to force drag when the child scroll container reaches the bound. + * + * @param delta delta in pixels to drag by + * + * @return the amount of [delta] consumed + */ + internal fun performDrag(delta: Float): Float { + val potentiallyConsumed = absoluteOffset.value + delta + val clamped = potentiallyConsumed.coerceIn(minBound, maxBound) + val deltaToConsume = clamped - absoluteOffset.value + if (abs(deltaToConsume) > 0) { + draggableState.dispatchRawDelta(deltaToConsume) + } + return deltaToConsume + } + + companion object { + /** + * The default [Saver] implementation for [SwipeableState]. + */ + fun Saver( + animationSpec: AnimationSpec, + confirmStateChange: (T) -> Boolean + ) = Saver, T>( + save = { it.currentValue }, + restore = { SwipeableState(it, animationSpec, confirmStateChange) } + ) + } +} + +/** + * Collects information about the ongoing swipe or animation in [swipeable]. + * + * To access this information, use [SwipeableState.progress]. + * + * @param from The state corresponding to the anchor we are moving away from. + * @param to The state corresponding to the anchor we are moving towards. + * @param fraction The fraction that the current position represents between [from] and [to]. + * Must be between `0` and `1`. + */ +@Immutable +@ExperimentalMaterial3Api +internal class SwipeProgress( + val from: T, + val to: T, + /*@FloatRange(from = 0.0, to = 1.0)*/ + val fraction: Float +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is SwipeProgress<*>) return false + + if (from != other.from) return false + if (to != other.to) return false + if (fraction != other.fraction) return false + + return true + } + + override fun hashCode(): Int { + var result = from?.hashCode() ?: 0 + result = 31 * result + (to?.hashCode() ?: 0) + result = 31 * result + fraction.hashCode() + return result + } + + override fun toString(): String { + return "SwipeProgress(from=$from, to=$to, fraction=$fraction)" + } +} + +/** + * Create and [remember] a [SwipeableState] with the default animation clock. + * + * @param initialValue The initial value of the state. + * @param animationSpec The default animation that will be used to animate to a new state. + * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change. + */ +@Composable +@ExperimentalMaterial3Api +internal fun rememberSwipeableState( + initialValue: T, + animationSpec: AnimationSpec = AnimationSpec, + confirmStateChange: (newValue: T) -> Boolean = { true } +): SwipeableState { + return rememberSaveable( + saver = SwipeableState.Saver( + animationSpec = animationSpec, + confirmStateChange = confirmStateChange + ) + ) { + SwipeableState( + initialValue = initialValue, + animationSpec = animationSpec, + confirmStateChange = confirmStateChange + ) + } +} + +/** + * Create and [remember] a [SwipeableState] which is kept in sync with another state, i.e.: + * 1. Whenever the [value] changes, the [SwipeableState] will be animated to that new value. + * 2. Whenever the value of the [SwipeableState] changes (e.g. after a swipe), the owner of the + * [value] will be notified to update their state to the new value of the [SwipeableState] by + * invoking [onValueChange]. If the owner does not update their state to the provided value for + * some reason, then the [SwipeableState] will perform a rollback to the previous, correct value. + */ +@Composable +@ExperimentalMaterial3Api +internal fun rememberSwipeableStateFor( + value: T, + onValueChange: (T) -> Unit, + animationSpec: AnimationSpec = AnimationSpec +): SwipeableState { + val swipeableState = remember { + SwipeableState( + initialValue = value, + animationSpec = animationSpec, + confirmStateChange = { true } + ) + } + val forceAnimationCheck = remember { mutableStateOf(false) } + LaunchedEffect(value, forceAnimationCheck.value) { + if (value != swipeableState.currentValue) { + swipeableState.animateTo(value) + } + } + DisposableEffect(swipeableState.currentValue) { + if (value != swipeableState.currentValue) { + onValueChange(swipeableState.currentValue) + forceAnimationCheck.value = !forceAnimationCheck.value + } + onDispose { } + } + return swipeableState +} + +/** + * Enable swipe gestures between a set of predefined states. + * + * To use this, you must provide a map of anchors (in pixels) to states (of type [T]). + * Note that this map cannot be empty and cannot have two anchors mapped to the same state. + * + * When a swipe is detected, the offset of the [SwipeableState] will be updated with the swipe + * delta. You should use this offset to move your content accordingly (see `Modifier.offsetPx`). + * When the swipe ends, the offset will be animated to one of the anchors and when that anchor is + * reached, the value of the [SwipeableState] will also be updated to the state corresponding to + * the new anchor. The target anchor is calculated based on the provided positional [thresholds]. + * + * Swiping is constrained between the minimum and maximum anchors. If the user attempts to swipe + * past these bounds, a resistance effect will be applied by default. The amount of resistance at + * each edge is specified by the [resistance] config. To disable all resistance, set it to `null`. + * + * @param T The type of the state. + * @param state The state of the [swipeable]. + * @param anchors Pairs of anchors and states, used to map anchors to states and vice versa. + * @param thresholds Specifies where the thresholds between the states are. The thresholds will be + * used to determine which state to animate to when swiping stops. This is represented as a lambda + * that takes two states and returns the threshold between them in the form of a [ThresholdConfig]. + * Note that the order of the states corresponds to the swipe direction. + * @param orientation The orientation in which the [swipeable] can be swiped. + * @param enabled Whether this [swipeable] is enabled and should react to the user's input. + * @param reverseDirection Whether to reverse the direction of the swipe, so a top to bottom + * swipe will behave like bottom to top, and a left to right swipe will behave like right to left. + * @param interactionSource Optional [MutableInteractionSource] that will passed on to + * the internal [Modifier.draggable]. + * @param resistance Controls how much resistance will be applied when swiping past the bounds. + * @param velocityThreshold The threshold (in dp per second) that the end velocity has to exceed + * in order to animate to the next state, even if the positional [thresholds] have not been reached. + */ +@ExperimentalMaterial3Api +internal fun Modifier.swipeable( + state: SwipeableState, + anchors: Map, + orientation: Orientation, + enabled: Boolean = true, + reverseDirection: Boolean = false, + interactionSource: MutableInteractionSource? = null, + thresholds: (from: T, to: T) -> ThresholdConfig = { _, _ -> FixedThreshold(56.dp) }, + resistance: ResistanceConfig? = resistanceConfig(anchors.keys), + velocityThreshold: Dp = VelocityThreshold +) = composed( + inspectorInfo = debugInspectorInfo { + name = "swipeable" + properties["state"] = state + properties["anchors"] = anchors + properties["orientation"] = orientation + properties["enabled"] = enabled + properties["reverseDirection"] = reverseDirection + properties["interactionSource"] = interactionSource + properties["thresholds"] = thresholds + properties["resistance"] = resistance + properties["velocityThreshold"] = velocityThreshold + } +) { + require(anchors.isNotEmpty()) { + "You must have at least one anchor." + } + require(anchors.values.distinct().count() == anchors.size) { + "You cannot have two anchors mapped to the same state." + } + val density = LocalDensity.current + state.ensureInit(anchors) + LaunchedEffect(anchors, state) { + val oldAnchors = state.anchors + state.anchors = anchors + state.resistance = resistance + state.thresholds = { a, b -> + val from = anchors.getValue(a) + val to = anchors.getValue(b) + with(thresholds(from, to)) { density.computeThreshold(a, b) } + } + with(density) { + state.velocityThreshold = velocityThreshold.toPx() + } + state.processNewAnchors(oldAnchors, anchors) + } + + Modifier.draggable( + orientation = orientation, + enabled = enabled, + reverseDirection = reverseDirection, + interactionSource = interactionSource, + startDragImmediately = state.isAnimationRunning, + onDragStopped = { velocity -> launch { state.performFling(velocity) } }, + state = state.draggableState + ) +} + +/** + * Interface to compute a threshold between two anchors/states in a [swipeable]. + * + * To define a [ThresholdConfig], consider using [FixedThreshold] and [FractionalThreshold]. + */ +@Stable +@ExperimentalMaterial3Api +internal interface ThresholdConfig { + /** + * Compute the value of the threshold (in pixels), once the values of the anchors are known. + */ + fun Density.computeThreshold(fromValue: Float, toValue: Float): Float +} + +/** + * A fixed threshold will be at an [offset] away from the first anchor. + * + * @param offset The offset (in dp) that the threshold will be at. + */ +@Immutable +@ExperimentalMaterial3Api +internal data class FixedThreshold(private val offset: Dp) : ThresholdConfig { + override fun Density.computeThreshold(fromValue: Float, toValue: Float): Float { + return fromValue + offset.toPx() * sign(toValue - fromValue) + } +} + +/** + * A fractional threshold will be at a [fraction] of the way between the two anchors. + * + * @param fraction The fraction (between 0 and 1) that the threshold will be at. + */ +@Immutable @ExperimentalMaterial3Api +internal data class FractionalThreshold( + /*@FloatRange(from = 0.0, to = 1.0)*/ + private val fraction: Float +) : ThresholdConfig { + override fun Density.computeThreshold(fromValue: Float, toValue: Float): Float { + return lerp(fromValue, toValue, fraction) + } +} + +/** + * Specifies how resistance is calculated in [swipeable]. + * + * There are two things needed to calculate resistance: the resistance basis determines how much + * overflow will be consumed to achieve maximum resistance, and the resistance factor determines + * the amount of resistance (the larger the resistance factor, the stronger the resistance). + * + * The resistance basis is usually either the size of the component which [swipeable] is applied + * to, or the distance between the minimum and maximum anchors. For a constructor in which the + * resistance basis defaults to the latter, consider using [resistanceConfig]. + * + * You may specify different resistance factors for each bound. Consider using one of the default + * resistance factors in [SwipeableDefaults]: `StandardResistanceFactor` to convey that the user + * has run out of things to see, and `StiffResistanceFactor` to convey that the user cannot swipe + * this right now. Also, you can set either factor to 0 to disable resistance at that bound. + * + * @param basis Specifies the maximum amount of overflow that will be consumed. Must be positive. + * @param factorAtMin The factor by which to scale the resistance at the minimum bound. + * Must not be negative. + * @param factorAtMax The factor by which to scale the resistance at the maximum bound. + * Must not be negative. + */ +@Immutable +internal class ResistanceConfig( + /*@FloatRange(from = 0.0, fromInclusive = false)*/ + val basis: Float, + /*@FloatRange(from = 0.0)*/ + val factorAtMin: Float = StandardResistanceFactor, + /*@FloatRange(from = 0.0)*/ + val factorAtMax: Float = StandardResistanceFactor +) { + fun computeResistance(overflow: Float): Float { + val factor = if (overflow < 0) factorAtMin else factorAtMax + if (factor == 0f) return 0f + val progress = (overflow / basis).coerceIn(-1f, 1f) + return basis / factor * sin(progress * PI.toFloat() / 2) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ResistanceConfig) return false + + if (basis != other.basis) return false + if (factorAtMin != other.factorAtMin) return false + if (factorAtMax != other.factorAtMax) return false + + return true + } + + override fun hashCode(): Int { + var result = basis.hashCode() + result = 31 * result + factorAtMin.hashCode() + result = 31 * result + factorAtMax.hashCode() + return result + } + + override fun toString(): String { + return "ResistanceConfig(basis=$basis, factorAtMin=$factorAtMin, factorAtMax=$factorAtMax)" + } +} + +/** + * Given an offset x and a set of anchors, return a list of anchors: + * 1. [ ] if the set of anchors is empty, + * 2. [ x' ] if x is equal to one of the anchors, accounting for a small rounding error, where x' + * is x rounded to the exact value of the matching anchor, + * 3. [ min ] if min is the minimum anchor and x < min, + * 4. [ max ] if max is the maximum anchor and x > max, or + * 5. [ a , b ] if a and b are anchors such that a < x < b and b - a is minimal. + */ +private fun findBounds( + offset: Float, + anchors: Set +): List { + // Find the anchors the target lies between with a little bit of rounding error. + val a = anchors.filter { it <= offset + 0.001 }.maxOrNull() + val b = anchors.filter { it >= offset - 0.001 }.minOrNull() + + return when { + a == null -> + // case 1 or 3 + listOfNotNull(b) + b == null -> + // case 4 + listOf(a) + a == b -> + // case 2 + // Can't return offset itself here since it might not be exactly equal + // to the anchor, despite being considered an exact match. + listOf(a) + else -> + // case 5 + listOf(a, b) + } +} + +private fun computeTarget( + offset: Float, + lastValue: Float, + anchors: Set, + thresholds: (Float, Float) -> Float, + velocity: Float, + velocityThreshold: Float +): Float { + val bounds = findBounds(offset, anchors) + return when (bounds.size) { + 0 -> lastValue + 1 -> bounds[0] + else -> { + val lower = bounds[0] + val upper = bounds[1] + if (lastValue <= offset) { + // Swiping from lower to upper (positive). + if (velocity >= velocityThreshold) { + return upper + } else { + val threshold = thresholds(lower, upper) + if (offset < threshold) lower else upper + } + } else { + // Swiping from upper to lower (negative). + if (velocity <= -velocityThreshold) { + return lower + } else { + val threshold = thresholds(upper, lower) + if (offset > threshold) upper else lower + } + } + } + } +} + +private fun Map.getOffset(state: T): Float? { + return entries.firstOrNull { it.value == state }?.key +} + +/** + * Contains useful defaults for [swipeable] and [SwipeableState]. + */ +internal object SwipeableDefaults { + /** + * The default animation used by [SwipeableState]. + */ + internal val AnimationSpec = SpringSpec() + + /** + * The default velocity threshold (1.8 dp per millisecond) used by [swipeable]. + */ + internal val VelocityThreshold = 125.dp + + /** + * A stiff resistance factor which indicates that swiping isn't available right now. + */ + const val StiffResistanceFactor = 20f + + /** + * A standard resistance factor which indicates that the user has run out of things to see. + */ + const val StandardResistanceFactor = 10f + + /** + * The default resistance config used by [swipeable]. + * + * This returns `null` if there is one anchor. If there are at least two anchors, it returns + * a [ResistanceConfig] with the resistance basis equal to the distance between the two bounds. + */ + internal fun resistanceConfig( + anchors: Set, + factorAtMin: Float = StandardResistanceFactor, + factorAtMax: Float = StandardResistanceFactor + ): ResistanceConfig? { + return if (anchors.size <= 1) { + null + } else { + val basis = anchors.maxOrNull()!! - anchors.minOrNull()!! + ResistanceConfig(basis, factorAtMin, factorAtMax) + } + } +} + +// temp default nested scroll connection for swipeables which desire as an opt in +// revisit in b/174756744 as all types will have their own specific connection probably +@ExperimentalMaterial3Api +internal val SwipeableState.PreUpPostDownNestedScrollConnection: NestedScrollConnection + get() = object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val delta = available.toFloat() + return if (delta < 0 && source == NestedScrollSource.Drag) { + performDrag(delta).toOffset() + } else { + Offset.Zero + } + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + return if (source == NestedScrollSource.Drag) { + performDrag(available.toFloat()).toOffset() + } else { + Offset.Zero + } + } + + override suspend fun onPreFling(available: Velocity): Velocity { + val toFling = Offset(available.x, available.y).toFloat() + return if (toFling < 0 && offset.value > minBound) { + performFling(velocity = toFling) + // since we go to the anchor with tween settling, consume all for the best UX + available + } else { + Velocity.Zero + } + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + performFling(velocity = Offset(available.x, available.y).toFloat()) + return available + } + + private fun Float.toOffset(): Offset = Offset(0f, this) + + private fun Offset.toFloat(): Float = this.y + } \ No newline at end of file diff --git a/presentation/src/main/java/com/android254/presentation/feed/FeedViewModel.kt b/presentation/src/main/java/com/android254/presentation/feed/FeedViewModel.kt index e2161697..91084dc6 100644 --- a/presentation/src/main/java/com/android254/presentation/feed/FeedViewModel.kt +++ b/presentation/src/main/java/com/android254/presentation/feed/FeedViewModel.kt @@ -1,3 +1,18 @@ +/* + * Copyright 2023 DroidconKE + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package com.android254.presentation.feed import androidx.lifecycle.ViewModel @@ -13,7 +28,7 @@ import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class FeedViewModel @Inject constructor( +class FeedViewModel @Inject constructor( private val feedRepo: FeedRepo ) : ViewModel() { private val _feedsState = MutableStateFlow(FeedUIState.Loading) diff --git a/presentation/src/main/java/com/android254/presentation/feed/view/FeedComponent.kt b/presentation/src/main/java/com/android254/presentation/feed/view/FeedComponent.kt index 49b4be1e..29df19b9 100644 --- a/presentation/src/main/java/com/android254/presentation/feed/view/FeedComponent.kt +++ b/presentation/src/main/java/com/android254/presentation/feed/view/FeedComponent.kt @@ -15,17 +15,13 @@ */ package com.android254.presentation.feed.view -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Newspaper import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon @@ -35,7 +31,7 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -45,6 +41,9 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.android254.presentation.models.FeedUI import com.droidconke.chai.ChaiDCKE22Theme import com.droidconke.chai.atoms.ChaiBlue import com.droidconke.chai.atoms.ChaiLightGrey @@ -53,7 +52,11 @@ import com.droidconke.chai.atoms.MontserratBold import ke.droidcon.kotlin.presentation.R @Composable -fun FeedComponent(modifier: Modifier, onClickItem: (Int) -> Unit) { +fun FeedComponent( + modifier: Modifier, + feedPresentationModel: FeedUI, + onClickItem: (Int) -> Unit +) { Card( modifier = modifier .fillMaxWidth() @@ -70,7 +73,7 @@ fun FeedComponent(modifier: Modifier, onClickItem: (Int) -> Unit) { val textFromNetwork = stringResource(id = R.string.placeholder_long_text) Text( - text = textFromNetwork, + text = feedPresentationModel.body, color = MaterialTheme.colorScheme.onBackground, fontSize = MaterialTheme.typography.bodyMedium.fontSize, fontWeight = MaterialTheme.typography.bodyMedium.fontWeight, @@ -80,13 +83,11 @@ fun FeedComponent(modifier: Modifier, onClickItem: (Int) -> Unit) { overflow = TextOverflow.Ellipsis ) - Image( - modifier = Modifier - .fillMaxWidth() - .height(220.dp) - .clip(RoundedCornerShape(6.dp)), - imageVector = Icons.Rounded.Newspaper, - contentDescription = textFromNetwork + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(feedPresentationModel.image) + .build(), + contentDescription = stringResource(id = R.string.feed_image) ) Row( @@ -135,7 +136,11 @@ fun FeedComponent(modifier: Modifier, onClickItem: (Int) -> Unit) { @Composable fun Preview() { ChaiDCKE22Theme { - FeedComponent(modifier = Modifier) { - } + FeedComponent( + modifier = Modifier, + feedPresentationModel = + FeedUI("Feed", "Feed feed", "test", "", "", ""), + onClickItem = {} + ) } } \ No newline at end of file diff --git a/presentation/src/main/java/com/android254/presentation/feed/view/FeedMappers.kt b/presentation/src/main/java/com/android254/presentation/feed/view/FeedMappers.kt index 6e25ab7e..159b90d8 100644 --- a/presentation/src/main/java/com/android254/presentation/feed/view/FeedMappers.kt +++ b/presentation/src/main/java/com/android254/presentation/feed/view/FeedMappers.kt @@ -1,3 +1,18 @@ +/* + * Copyright 2023 DroidconKE + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package com.android254.presentation.feed.view import com.android254.domain.models.Feed diff --git a/presentation/src/main/java/com/android254/presentation/feed/view/FeedScreen.kt b/presentation/src/main/java/com/android254/presentation/feed/view/FeedScreen.kt index c2f1b146..89dd2ce3 100644 --- a/presentation/src/main/java/com/android254/presentation/feed/view/FeedScreen.kt +++ b/presentation/src/main/java/com/android254/presentation/feed/view/FeedScreen.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 DroidconKE + * Copyright 2023 DroidconKE * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,35 +16,46 @@ package com.android254.presentation.feed.view import android.content.res.Configuration -import androidx.activity.compose.BackHandler import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.* +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.android254.presentation.common.bottomsheet.BottomSheetScaffold +import com.android254.presentation.common.bottomsheet.rememberBottomSheetScaffoldState import com.android254.presentation.common.components.DroidconAppBarWithFeedbackButton +import com.android254.presentation.feed.FeedViewModel import com.droidconke.chai.ChaiDCKE22Theme import kotlinx.coroutines.launch @Composable fun FeedScreen( - navigateToFeedbackScreen: () -> Unit = {} + navigateToFeedbackScreen: () -> Unit = {}, + feedViewModel: FeedViewModel = hiltViewModel() ) { + val bottomSheetScaffoldState = rememberBottomSheetScaffoldState() val scope = rememberCoroutineScope() - val bottomSheetState = rememberSheetState( - skipHalfExpanded = true - ) - - BackHandler(bottomSheetState.isVisible) { - scope.launch { bottomSheetState.hide() } - } - - Scaffold( + feedViewModel.fetchFeed() + val feedUIState = feedViewModel.feedsState.collectAsState().value + BottomSheetScaffold( + sheetContent = { + FeedShareSection() + }, + scaffoldState = bottomSheetScaffoldState, topBar = { DroidconAppBarWithFeedbackButton( onButtonClick = { @@ -52,40 +63,40 @@ fun FeedScreen( }, userProfile = "https://media-exp1.licdn.com/dms/image/C4D03AQGn58utIO-x3w/profile-displayphoto-shrink_200_200/0/1637478114039?e=2147483647&v=beta&t=3kIon0YJQNHZojD3Dt5HVODJqHsKdf2YKP1SfWeROnI" ) - } + }, + sheetShape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), + sheetElevation = 16.dp, + sheetPeekHeight = 0.dp ) { paddingValues -> Box( modifier = Modifier .padding(paddingValues) .fillMaxSize() ) { - LazyColumn( - modifier = Modifier.testTag("feeds_lazy_column"), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - items(count = 10) { - FeedComponent(modifier = Modifier.fillMaxWidth()) { - scope.launch { - bottomSheetState.show() + when (feedUIState) { + is FeedUIState.Error -> {} + FeedUIState.Loading -> {} + is FeedUIState.Success -> { + LazyColumn( + modifier = Modifier.testTag("feeds_lazy_column"), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(feedUIState.feeds) { feedPresentationModel -> + FeedComponent(modifier = Modifier.fillMaxWidth(), feedPresentationModel) { + scope.launch { + if (bottomSheetScaffoldState.bottomSheetState.isCollapsed) { + bottomSheetScaffoldState.bottomSheetState.expand() + } else { + bottomSheetScaffoldState.bottomSheetState.collapse() + } + } + } } } } } } } - - if (bottomSheetState.isVisible) { - ModalBottomSheet( - sheetState = bottomSheetState, - onDismissRequest = { - scope.launch { - bottomSheetState.hide() - } - } - ) { - FeedShareSection() - } - } } @Preview(name = "Light Mode", showBackground = true) diff --git a/presentation/src/main/java/com/android254/presentation/feed/view/FeedUIState.kt b/presentation/src/main/java/com/android254/presentation/feed/view/FeedUIState.kt index b4c8b0f3..a75ab2d7 100644 --- a/presentation/src/main/java/com/android254/presentation/feed/view/FeedUIState.kt +++ b/presentation/src/main/java/com/android254/presentation/feed/view/FeedUIState.kt @@ -1,3 +1,18 @@ +/* + * Copyright 2023 DroidconKE + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package com.android254.presentation.feed.view import com.android254.presentation.models.FeedUI diff --git a/presentation/src/main/java/com/android254/presentation/home/screen/HomeScreen.kt b/presentation/src/main/java/com/android254/presentation/home/screen/HomeScreen.kt index deb00bbc..32b20606 100644 --- a/presentation/src/main/java/com/android254/presentation/home/screen/HomeScreen.kt +++ b/presentation/src/main/java/com/android254/presentation/home/screen/HomeScreen.kt @@ -82,7 +82,7 @@ fun HomeScreen( HomeHeaderSection() HomeBannerSection(homeViewState) HomeSpacer() - if (homeViewState.isSessionsSectionVisible){ + if (homeViewState.isSessionsSectionVisible) { HomeSessionSection( sessions = homeViewState.sessions, onSessionClick = onSessionClicked, @@ -90,7 +90,7 @@ fun HomeScreen( ) HomeSpacer() } - if (homeViewState.isSpeakersSectionVisible){ + if (homeViewState.isSpeakersSectionVisible) { HomeSpeakersSection( speakers = homeViewState.speakers, navigateToSpeakers = navigateToSpeakers, diff --git a/presentation/src/main/java/com/android254/presentation/models/FeedUI.kt b/presentation/src/main/java/com/android254/presentation/models/FeedUI.kt index 0b2d88cc..9df74323 100644 --- a/presentation/src/main/java/com/android254/presentation/models/FeedUI.kt +++ b/presentation/src/main/java/com/android254/presentation/models/FeedUI.kt @@ -1,3 +1,18 @@ +/* + * Copyright 2023 DroidconKE + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package com.android254.presentation.models data class FeedUI( @@ -7,4 +22,4 @@ data class FeedUI( val url: String, val image: String?, val createdAt: String -) +) \ No newline at end of file diff --git a/presentation/src/main/java/com/android254/presentation/sessions/view/SessionsViewModel.kt b/presentation/src/main/java/com/android254/presentation/sessions/view/SessionsViewModel.kt index e2153407..8d63a37d 100644 --- a/presentation/src/main/java/com/android254/presentation/sessions/view/SessionsViewModel.kt +++ b/presentation/src/main/java/com/android254/presentation/sessions/view/SessionsViewModel.kt @@ -202,7 +202,9 @@ class SessionsViewModel @Inject constructor( } if (it.sessionTypes.isNotEmpty()) { val items = it.sessionTypes.joinToString( - separator, prefix, postfix + separator, + prefix, + postfix ) { value -> "'${value.lowercase()}'" } if (stringBuilder.isNotEmpty()) stringBuilder.append(" AND ") stringBuilder.append("LOWER (sessionFormat) IN $items") @@ -218,7 +220,9 @@ class SessionsViewModel @Inject constructor( } val where = if (stringBuilder.isNotEmpty()) { "WHERE $stringBuilder" - } else stringBuilder + } else { + stringBuilder + } return "SELECT * FROM sessions $where".also { Timber.i("QUERY = $it") } } diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index 38a506a6..400aad0b 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -76,4 +76,6 @@ Beginner Intermediate Advanced + + Feed image From 765da0c1845e52971354b53757317a3b76ca258d Mon Sep 17 00:00:00 2001 From: jacqui Date: Tue, 6 Jun 2023 22:59:37 +0300 Subject: [PATCH 06/29] Changing API BaseURL Test --- .../com/android254/data/network/apis/FeedApiTest.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/data/src/test/java/com/android254/data/network/apis/FeedApiTest.kt b/data/src/test/java/com/android254/data/network/apis/FeedApiTest.kt index 4c622eed..5c6a6611 100644 --- a/data/src/test/java/com/android254/data/network/apis/FeedApiTest.kt +++ b/data/src/test/java/com/android254/data/network/apis/FeedApiTest.kt @@ -19,11 +19,15 @@ import com.android254.data.network.models.responses.FeedDTO import com.android254.data.network.util.HttpClientFactory import com.android254.data.network.util.MockTokenProvider import com.android254.data.network.util.RemoteFeatureToggle -import com.android254.data.network.util.provideEventBaseUrl +import com.android254.data.network.util.provideBaseUrl import com.android254.domain.models.DataResult import com.google.firebase.remoteconfig.FirebaseRemoteConfig -import io.ktor.client.engine.mock.* -import io.ktor.http.* +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.engine.mock.respondOk +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.headersOf import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.hamcrest.CoreMatchers.`is` @@ -53,7 +57,7 @@ class FeedApiTest { assertThat(mockEngine.requestHistory.size, `is`(1)) mockEngine.requestHistory.first().run { - val expectedUrl = "${provideEventBaseUrl()}/feeds?page=2&per_page=50" + val expectedUrl = "${provideBaseUrl()}/feeds?page=2&per_page=50" assertThat(url.toString(), `is`(expectedUrl)) assertThat(method, `is`(HttpMethod.Get)) } From 7596394dff8253cf63fa632767d078e8dbab8839 Mon Sep 17 00:00:00 2001 From: jacqui Date: Tue, 6 Jun 2023 23:59:17 +0300 Subject: [PATCH 07/29] Adding tests in FeedScreenTest.kt file --- .../presentation/feed/view/FeedScreenTest.kt | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/presentation/src/test/java/com/android254/presentation/feed/view/FeedScreenTest.kt b/presentation/src/test/java/com/android254/presentation/feed/view/FeedScreenTest.kt index 4644b34a..a3d5b08b 100644 --- a/presentation/src/test/java/com/android254/presentation/feed/view/FeedScreenTest.kt +++ b/presentation/src/test/java/com/android254/presentation/feed/view/FeedScreenTest.kt @@ -19,7 +19,13 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick +import com.android254.domain.models.Feed +import com.android254.domain.models.ResourceResult +import com.android254.domain.repos.FeedRepo import com.android254.presentation.common.theme.DroidconKE2023Theme +import com.android254.presentation.feed.FeedViewModel +import io.mockk.coEvery +import io.mockk.mockk import org.junit.Before import org.junit.Rule import org.junit.Test @@ -32,6 +38,8 @@ import org.robolectric.shadows.ShadowLog @Config(instrumentedPackages = ["androidx.loader.content"]) class FeedScreenTest { + private val repo = mockk() + @get:Rule val composeTestRule = createComposeRule() @@ -43,9 +51,11 @@ class FeedScreenTest { @Test fun `should display feed items`() { + coEvery { repo.fetchFeed() } returns ResourceResult.Success(listOf(Feed("", "", "", "", "", ""))) + composeTestRule.setContent { DroidconKE2023Theme { - FeedScreen() + FeedScreen(feedViewModel = FeedViewModel(repo)) } } @@ -58,9 +68,11 @@ class FeedScreenTest { @Test fun `test share bottom sheet is shown`() { + coEvery { repo.fetchFeed() } returns ResourceResult.Success(listOf(Feed("", "", "", "", "", ""))) + composeTestRule.setContent { DroidconKE2023Theme { - FeedScreen() + FeedScreen(feedViewModel = FeedViewModel(repo)) } } composeTestRule.onNodeWithTag("share_button").assertExists() From 65653bcd41ef94e28a829e2dd3a726eb10e54de2 Mon Sep 17 00:00:00 2001 From: jacqui Date: Thu, 8 Jun 2023 02:38:15 +0300 Subject: [PATCH 08/29] Renamed api -> FeedApi --- data/src/main/java/com/android254/data/repos/FeedManager.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/data/src/main/java/com/android254/data/repos/FeedManager.kt b/data/src/main/java/com/android254/data/repos/FeedManager.kt index 7b5cca25..b871ed37 100644 --- a/data/src/main/java/com/android254/data/repos/FeedManager.kt +++ b/data/src/main/java/com/android254/data/repos/FeedManager.kt @@ -24,10 +24,10 @@ import com.android254.domain.repos.FeedRepo import javax.inject.Inject class FeedManager @Inject constructor( - private val api: FeedApi + private val FeedApi: FeedApi ) : FeedRepo { override suspend fun fetchFeed(): ResourceResult> { - return when (val result = api.fetchFeed(1, 100)) { + return when (val result = FeedApi.fetchFeed(1, 100)) { is DataResult.Empty -> { ResourceResult.Success(emptyList()) } From 94b61deb298e749221e241230685cf643a9dc165 Mon Sep 17 00:00:00 2001 From: jacqui Date: Thu, 8 Jun 2023 02:39:44 +0300 Subject: [PATCH 09/29] Changed ResourceResult> -> List --- domain/src/main/java/com/android254/domain/repos/FeedRepo.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/domain/src/main/java/com/android254/domain/repos/FeedRepo.kt b/domain/src/main/java/com/android254/domain/repos/FeedRepo.kt index 78eb68c7..957cacc7 100644 --- a/domain/src/main/java/com/android254/domain/repos/FeedRepo.kt +++ b/domain/src/main/java/com/android254/domain/repos/FeedRepo.kt @@ -16,8 +16,7 @@ package com.android254.domain.repos import com.android254.domain.models.Feed -import com.android254.domain.models.ResourceResult interface FeedRepo { - suspend fun fetchFeed(): ResourceResult> + suspend fun fetchFeed(): List } \ No newline at end of file From 23d9d50d17b24320f4ec5f32643515b3a5584f54 Mon Sep 17 00:00:00 2001 From: jacqui Date: Thu, 8 Jun 2023 02:50:23 +0300 Subject: [PATCH 10/29] Referencing String directly from the composable --- .../presentation/common/bottomsheet/Drawer.kt | 14 +++-- .../common/bottomsheet/Strings.kt | 52 ------------------- 2 files changed, 10 insertions(+), 56 deletions(-) delete mode 100644 presentation/src/main/java/com/android254/presentation/common/bottomsheet/Strings.kt diff --git a/presentation/src/main/java/com/android254/presentation/common/bottomsheet/Drawer.kt b/presentation/src/main/java/com/android254/presentation/common/bottomsheet/Drawer.kt index b1806bf7..8e50361c 100644 --- a/presentation/src/main/java/com/android254/presentation/common/bottomsheet/Drawer.kt +++ b/presentation/src/main/java/com/android254/presentation/common/bottomsheet/Drawer.kt @@ -44,12 +44,14 @@ import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.R import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.semantics.contentDescription @@ -420,7 +422,8 @@ fun ModalDrawer( }, color = scrimColor ) - val navigationMenu = getString(Strings.NavigationMenu) + val resources = LocalContext.current.resources + val navigationMenu = resources.getString(R.string.navigation_menu) Surface( modifier = with(LocalDensity.current) { Modifier @@ -559,7 +562,8 @@ fun BottomDrawer( }, visible = drawerState.targetValue != BottomDrawerValue.Closed ) - val navigationMenu = getString(Strings.NavigationMenu) + val resources = LocalContext.current.resources + val navigationMenu = resources.getString(R.string.navigation_menu) Surface( drawerConstraints .offset { IntOffset(x = 0, y = drawerState.offset.value.roundToInt()) } @@ -623,7 +627,8 @@ private fun BottomDrawerScrim( targetValue = if (visible) 1f else 0f, animationSpec = TweenSpec() ) - val closeDrawer = getString(Strings.CloseDrawer) + val resources = LocalContext.current.resources + val closeDrawer = resources.getString(R.string.close_drawer) val dismissModifier = if (visible) { Modifier .pointerInput(onDismiss) { @@ -654,7 +659,8 @@ private fun Scrim( fraction: () -> Float, color: Color ) { - val closeDrawer = getString(Strings.CloseDrawer) + val resources = LocalContext.current.resources + val closeDrawer = resources.getString(R.string.close_drawer) val dismissDrawer = if (open) { Modifier .pointerInput(onClose) { detectTapGestures { onClose() } } diff --git a/presentation/src/main/java/com/android254/presentation/common/bottomsheet/Strings.kt b/presentation/src/main/java/com/android254/presentation/common/bottomsheet/Strings.kt deleted file mode 100644 index 103dcbdf..00000000 --- a/presentation/src/main/java/com/android254/presentation/common/bottomsheet/Strings.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright 2023 DroidconKE - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android254.presentation.common.bottomsheet - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -import androidx.compose.ui.R -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext - -@Immutable -@kotlin.jvm.JvmInline -value class Strings private constructor(@Suppress("unused") private val value: Int) { - companion object { - val NavigationMenu = Strings(0) - val CloseDrawer = Strings(1) - val CloseSheet = Strings(2) - val DefaultErrorMessage = Strings(3) - val ExposedDropdownMenu = Strings(4) - val SliderRangeStart = Strings(5) - val SliderRangeEnd = Strings(6) - } -} - -@Composable -fun getString(string: Strings): String { - LocalConfiguration.current - val resources = LocalContext.current.resources - return when (string) { - Strings.NavigationMenu -> resources.getString(R.string.navigation_menu) - Strings.CloseDrawer -> resources.getString(R.string.close_drawer) - Strings.CloseSheet -> resources.getString(R.string.close_sheet) - Strings.DefaultErrorMessage -> resources.getString(R.string.default_error_message) - Strings.ExposedDropdownMenu -> resources.getString(R.string.dropdown_menu) - Strings.SliderRangeStart -> resources.getString(R.string.range_start) - Strings.SliderRangeEnd -> resources.getString(R.string.range_end) - else -> "" - } -} \ No newline at end of file From 0d06003c259e4f22d2e93369f0cc8f0fdd56810c Mon Sep 17 00:00:00 2001 From: jacqui Date: Thu, 8 Jun 2023 03:40:00 +0300 Subject: [PATCH 11/29] Modifying FeedScreenTest.kt file --- .../com/android254/data/repos/FeedManager.kt | 20 ++++++---------- .../com/android254/domain/repos/FeedRepo.kt | 2 +- .../presentation/feed/FeedViewModel.kt | 24 +++++++------------ .../presentation/feed/view/FeedScreen.kt | 3 +-- .../presentation/feed/view/FeedScreenTest.kt | 5 ++-- 5 files changed, 19 insertions(+), 35 deletions(-) diff --git a/data/src/main/java/com/android254/data/repos/FeedManager.kt b/data/src/main/java/com/android254/data/repos/FeedManager.kt index b871ed37..ee3fac5a 100644 --- a/data/src/main/java/com/android254/data/repos/FeedManager.kt +++ b/data/src/main/java/com/android254/data/repos/FeedManager.kt @@ -19,34 +19,28 @@ import com.android254.data.network.apis.FeedApi import com.android254.data.repos.mappers.toDomain import com.android254.domain.models.DataResult import com.android254.domain.models.Feed -import com.android254.domain.models.ResourceResult import com.android254.domain.repos.FeedRepo import javax.inject.Inject class FeedManager @Inject constructor( private val FeedApi: FeedApi ) : FeedRepo { - override suspend fun fetchFeed(): ResourceResult> { + override suspend fun fetchFeed(): List? { return when (val result = FeedApi.fetchFeed(1, 100)) { is DataResult.Empty -> { - ResourceResult.Success(emptyList()) + emptyList() } + is DataResult.Error -> { - ResourceResult.Error( - result.message, - networkError = result.message.contains("network", ignoreCase = true) - ) + null } + is DataResult.Success -> { val data = result.data - - ResourceResult.Success( - data.map { it.toDomain() } - ) + data.map { it.toDomain() } } - else -> { - ResourceResult.Success(emptyList()) + emptyList() } } } diff --git a/domain/src/main/java/com/android254/domain/repos/FeedRepo.kt b/domain/src/main/java/com/android254/domain/repos/FeedRepo.kt index 957cacc7..e22d35bc 100644 --- a/domain/src/main/java/com/android254/domain/repos/FeedRepo.kt +++ b/domain/src/main/java/com/android254/domain/repos/FeedRepo.kt @@ -18,5 +18,5 @@ package com.android254.domain.repos import com.android254.domain.models.Feed interface FeedRepo { - suspend fun fetchFeed(): List + suspend fun fetchFeed(): List? } \ No newline at end of file diff --git a/presentation/src/main/java/com/android254/presentation/feed/FeedViewModel.kt b/presentation/src/main/java/com/android254/presentation/feed/FeedViewModel.kt index 91084dc6..0af14586 100644 --- a/presentation/src/main/java/com/android254/presentation/feed/FeedViewModel.kt +++ b/presentation/src/main/java/com/android254/presentation/feed/FeedViewModel.kt @@ -15,15 +15,15 @@ */ package com.android254.presentation.feed +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.android254.domain.models.ResourceResult import com.android254.domain.repos.FeedRepo import com.android254.presentation.feed.view.FeedUIState import com.android254.presentation.feed.view.toPresentation import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import javax.inject.Inject @@ -31,22 +31,14 @@ import javax.inject.Inject class FeedViewModel @Inject constructor( private val feedRepo: FeedRepo ) : ViewModel() { - private val _feedsState = MutableStateFlow(FeedUIState.Loading) - val feedsState: StateFlow get() = _feedsState + var viewState: FeedUIState by mutableStateOf(FeedUIState.Loading) + private set fun fetchFeed() { viewModelScope.launch { - when (val value = feedRepo.fetchFeed()) { - is ResourceResult.Error -> _feedsState.value = FeedUIState.Error(value.message) - is ResourceResult.Loading -> _feedsState.value = FeedUIState.Loading - is ResourceResult.Success -> { - value.data?.let { - _feedsState.value = FeedUIState.Success( - it.map { feed -> feed.toPresentation() } - ) - } - } - else -> _feedsState.value = FeedUIState.Error("Unknown") + viewState = when (val value = feedRepo.fetchFeed()) { + null -> FeedUIState.Error("Error getting result") + else -> FeedUIState.Success(value.map { it.toPresentation() }) } } } diff --git a/presentation/src/main/java/com/android254/presentation/feed/view/FeedScreen.kt b/presentation/src/main/java/com/android254/presentation/feed/view/FeedScreen.kt index 89dd2ce3..9a47388d 100644 --- a/presentation/src/main/java/com/android254/presentation/feed/view/FeedScreen.kt +++ b/presentation/src/main/java/com/android254/presentation/feed/view/FeedScreen.kt @@ -28,7 +28,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag @@ -50,7 +49,7 @@ fun FeedScreen( val bottomSheetScaffoldState = rememberBottomSheetScaffoldState() val scope = rememberCoroutineScope() feedViewModel.fetchFeed() - val feedUIState = feedViewModel.feedsState.collectAsState().value + val feedUIState = feedViewModel.viewState BottomSheetScaffold( sheetContent = { FeedShareSection() diff --git a/presentation/src/test/java/com/android254/presentation/feed/view/FeedScreenTest.kt b/presentation/src/test/java/com/android254/presentation/feed/view/FeedScreenTest.kt index a3d5b08b..bd14a26d 100644 --- a/presentation/src/test/java/com/android254/presentation/feed/view/FeedScreenTest.kt +++ b/presentation/src/test/java/com/android254/presentation/feed/view/FeedScreenTest.kt @@ -20,7 +20,6 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import com.android254.domain.models.Feed -import com.android254.domain.models.ResourceResult import com.android254.domain.repos.FeedRepo import com.android254.presentation.common.theme.DroidconKE2023Theme import com.android254.presentation.feed.FeedViewModel @@ -51,7 +50,7 @@ class FeedScreenTest { @Test fun `should display feed items`() { - coEvery { repo.fetchFeed() } returns ResourceResult.Success(listOf(Feed("", "", "", "", "", ""))) + coEvery { repo.fetchFeed() } returns listOf(Feed("", "", "", "", "", "")) composeTestRule.setContent { DroidconKE2023Theme { @@ -68,7 +67,7 @@ class FeedScreenTest { @Test fun `test share bottom sheet is shown`() { - coEvery { repo.fetchFeed() } returns ResourceResult.Success(listOf(Feed("", "", "", "", "", ""))) + coEvery { repo.fetchFeed() } returns listOf(Feed("", "", "", "", "", "")) composeTestRule.setContent { DroidconKE2023Theme { From b1f0d051a2bfee48dc9f65e03f3d9b44cb0b8c6b Mon Sep 17 00:00:00 2001 From: "brian.orwe" Date: Mon, 5 Jun 2023 21:45:05 +0300 Subject: [PATCH 12/29] Create droidcon_logo_dark.xml --- .../main/res/drawable/droidcon_logo_dark.xml | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 presentation/src/main/res/drawable/droidcon_logo_dark.xml diff --git a/presentation/src/main/res/drawable/droidcon_logo_dark.xml b/presentation/src/main/res/drawable/droidcon_logo_dark.xml new file mode 100644 index 00000000..8bc10e0b --- /dev/null +++ b/presentation/src/main/res/drawable/droidcon_logo_dark.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + From edc97d552e5af0c4fcc219051a2d6a29703263cf Mon Sep 17 00:00:00 2001 From: "brian.orwe" Date: Mon, 5 Jun 2023 22:22:36 +0300 Subject: [PATCH 13/29] check mode in code --- .../presentation/common/components/DroidconAppBar.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/presentation/src/main/java/com/android254/presentation/common/components/DroidconAppBar.kt b/presentation/src/main/java/com/android254/presentation/common/components/DroidconAppBar.kt index 0fc2d79c..cadb9261 100644 --- a/presentation/src/main/java/com/android254/presentation/common/components/DroidconAppBar.kt +++ b/presentation/src/main/java/com/android254/presentation/common/components/DroidconAppBar.kt @@ -16,6 +16,7 @@ package com.android254.presentation.common.components import androidx.compose.foundation.Image +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -37,6 +38,7 @@ fun DroidconAppBar( modifier: Modifier = Modifier, onActionClicked: () -> Unit = {} ) { + Row( modifier = modifier .fillMaxWidth() @@ -46,7 +48,7 @@ fun DroidconAppBar( verticalAlignment = Alignment.CenterVertically ) { Image( - painter = painterResource(id = R.drawable.droidcon_logo), + painter = if(isSystemInDarkTheme()) painterResource(id = R.drawable.droidcon_logo_dark) else painterResource(id = R.drawable.droidcon_logo), contentDescription = stringResource(id = R.string.logo) ) Spacer(modifier = Modifier.weight(1f)) From bec52c473da4a4c7b0cccb392e1b84b032f32ac3 Mon Sep 17 00:00:00 2001 From: "brian.orwe" Date: Mon, 5 Jun 2023 23:04:30 +0300 Subject: [PATCH 14/29] Added codeAnalysis.bat for windows uers and ran it --- .github/pull_request_template.md | 2 +- codeAnalysis.bat | 2 ++ .../presentation/common/components/DroidconAppBar.kt | 3 +-- 3 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 codeAnalysis.bat diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index abcdbd36..7f774c96 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -6,7 +6,7 @@ does in a short paragraph._ please check the below boxes - [ ] I have followed the coding conventions - [ ] I have added/updated necessary tests - [ ] I have tested the changes added on a physical device -- [ ] I have run `./codeAnalysis.sh` to make sure all lint/formatting checks have been done. +- [ ] I have run `./codeAnalysis.sh` on linux/unix or `codeAnalysys.bat` on windows to make sure all lint/formatting checks have been done. ## Closes/Fixes Issues _Declare any issues by typing `fixes #1` or `closes #1` for example so that the automation can kick diff --git a/codeAnalysis.bat b/codeAnalysis.bat new file mode 100644 index 00000000..e7308bd6 --- /dev/null +++ b/codeAnalysis.bat @@ -0,0 +1,2 @@ +@echo off +.\gradlew ktlintFormat && .\gradlew ktlintCheck && .\gradlew detekt && .\gradlew spotlessApply diff --git a/presentation/src/main/java/com/android254/presentation/common/components/DroidconAppBar.kt b/presentation/src/main/java/com/android254/presentation/common/components/DroidconAppBar.kt index cadb9261..782e3816 100644 --- a/presentation/src/main/java/com/android254/presentation/common/components/DroidconAppBar.kt +++ b/presentation/src/main/java/com/android254/presentation/common/components/DroidconAppBar.kt @@ -38,7 +38,6 @@ fun DroidconAppBar( modifier: Modifier = Modifier, onActionClicked: () -> Unit = {} ) { - Row( modifier = modifier .fillMaxWidth() @@ -48,7 +47,7 @@ fun DroidconAppBar( verticalAlignment = Alignment.CenterVertically ) { Image( - painter = if(isSystemInDarkTheme()) painterResource(id = R.drawable.droidcon_logo_dark) else painterResource(id = R.drawable.droidcon_logo), + painter = if (isSystemInDarkTheme()) painterResource(id = R.drawable.droidcon_logo_dark) else painterResource(id = R.drawable.droidcon_logo), contentDescription = stringResource(id = R.string.logo) ) Spacer(modifier = Modifier.weight(1f)) From 949f1a870e17269cc7603564e65ba5eeebd5db27 Mon Sep 17 00:00:00 2001 From: "brian.orwe" Date: Mon, 5 Jun 2023 23:30:21 +0300 Subject: [PATCH 15/29] set it for all AppBars' --- .../presentation/common/components/DroidconAppBar.kt | 3 ++- .../common/components/DroidconAppBarWithFeedbackButton.kt | 4 +++- .../common/components/DroidconAppBarWithFilter.kt | 4 +++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/presentation/src/main/java/com/android254/presentation/common/components/DroidconAppBar.kt b/presentation/src/main/java/com/android254/presentation/common/components/DroidconAppBar.kt index 782e3816..353e7733 100644 --- a/presentation/src/main/java/com/android254/presentation/common/components/DroidconAppBar.kt +++ b/presentation/src/main/java/com/android254/presentation/common/components/DroidconAppBar.kt @@ -47,7 +47,8 @@ fun DroidconAppBar( verticalAlignment = Alignment.CenterVertically ) { Image( - painter = if (isSystemInDarkTheme()) painterResource(id = R.drawable.droidcon_logo_dark) else painterResource(id = R.drawable.droidcon_logo), + painter = if (isSystemInDarkTheme()) painterResource(id = R.drawable.droidcon_logo_dark) + else painterResource(id = R.drawable.droidcon_logo), contentDescription = stringResource(id = R.string.logo) ) Spacer(modifier = Modifier.weight(1f)) diff --git a/presentation/src/main/java/com/android254/presentation/common/components/DroidconAppBarWithFeedbackButton.kt b/presentation/src/main/java/com/android254/presentation/common/components/DroidconAppBarWithFeedbackButton.kt index 8cb81c96..3bb883b4 100644 --- a/presentation/src/main/java/com/android254/presentation/common/components/DroidconAppBarWithFeedbackButton.kt +++ b/presentation/src/main/java/com/android254/presentation/common/components/DroidconAppBarWithFeedbackButton.kt @@ -18,6 +18,7 @@ package com.android254.presentation.common.components import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -59,7 +60,8 @@ fun DroidconAppBarWithFeedbackButton( verticalAlignment = Alignment.CenterVertically ) { Image( - painter = painterResource(id = R.drawable.droidcon_logo), + painter = if (isSystemInDarkTheme()) painterResource(id = R.drawable.droidcon_logo_dark) + else painterResource(id = R.drawable.droidcon_logo), contentDescription = stringResource(id = R.string.logo) ) Spacer(modifier = Modifier.weight(1f)) diff --git a/presentation/src/main/java/com/android254/presentation/common/components/DroidconAppBarWithFilter.kt b/presentation/src/main/java/com/android254/presentation/common/components/DroidconAppBarWithFilter.kt index b322038b..0181eefb 100644 --- a/presentation/src/main/java/com/android254/presentation/common/components/DroidconAppBarWithFilter.kt +++ b/presentation/src/main/java/com/android254/presentation/common/components/DroidconAppBarWithFilter.kt @@ -18,6 +18,7 @@ package com.android254.presentation.common.components import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -54,7 +55,8 @@ fun DroidconAppBarWithFilter( verticalAlignment = Alignment.CenterVertically ) { Image( - painter = painterResource(id = R.drawable.droidcon_logo), + painter = if (isSystemInDarkTheme()) painterResource(id = R.drawable.droidcon_logo_dark) + else painterResource(id = R.drawable.droidcon_logo), contentDescription = stringResource(id = R.string.logo) ) Spacer(modifier = Modifier.weight(1f)) From 45c4dbefb58f7ecaf7e7230a84c0032e55c0187d Mon Sep 17 00:00:00 2001 From: "brian.orwe" Date: Tue, 6 Jun 2023 16:05:23 +0300 Subject: [PATCH 16/29] use if with id --- .../presentation/common/components/DroidconAppBar.kt | 3 +-- .../common/components/DroidconAppBarWithFeedbackButton.kt | 3 +-- .../presentation/common/components/DroidconAppBarWithFilter.kt | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/presentation/src/main/java/com/android254/presentation/common/components/DroidconAppBar.kt b/presentation/src/main/java/com/android254/presentation/common/components/DroidconAppBar.kt index 353e7733..ca8c594f 100644 --- a/presentation/src/main/java/com/android254/presentation/common/components/DroidconAppBar.kt +++ b/presentation/src/main/java/com/android254/presentation/common/components/DroidconAppBar.kt @@ -47,8 +47,7 @@ fun DroidconAppBar( verticalAlignment = Alignment.CenterVertically ) { Image( - painter = if (isSystemInDarkTheme()) painterResource(id = R.drawable.droidcon_logo_dark) - else painterResource(id = R.drawable.droidcon_logo), + painter = painterResource(id = if (isSystemInDarkTheme()) R.drawable.droidcon_logo_dark else R.drawable.droidcon_logo), contentDescription = stringResource(id = R.string.logo) ) Spacer(modifier = Modifier.weight(1f)) diff --git a/presentation/src/main/java/com/android254/presentation/common/components/DroidconAppBarWithFeedbackButton.kt b/presentation/src/main/java/com/android254/presentation/common/components/DroidconAppBarWithFeedbackButton.kt index 3bb883b4..7ca58a7a 100644 --- a/presentation/src/main/java/com/android254/presentation/common/components/DroidconAppBarWithFeedbackButton.kt +++ b/presentation/src/main/java/com/android254/presentation/common/components/DroidconAppBarWithFeedbackButton.kt @@ -60,8 +60,7 @@ fun DroidconAppBarWithFeedbackButton( verticalAlignment = Alignment.CenterVertically ) { Image( - painter = if (isSystemInDarkTheme()) painterResource(id = R.drawable.droidcon_logo_dark) - else painterResource(id = R.drawable.droidcon_logo), + painter = painterResource(id = if (isSystemInDarkTheme()) R.drawable.droidcon_logo_dark else R.drawable.droidcon_logo), contentDescription = stringResource(id = R.string.logo) ) Spacer(modifier = Modifier.weight(1f)) diff --git a/presentation/src/main/java/com/android254/presentation/common/components/DroidconAppBarWithFilter.kt b/presentation/src/main/java/com/android254/presentation/common/components/DroidconAppBarWithFilter.kt index 0181eefb..337cd7b8 100644 --- a/presentation/src/main/java/com/android254/presentation/common/components/DroidconAppBarWithFilter.kt +++ b/presentation/src/main/java/com/android254/presentation/common/components/DroidconAppBarWithFilter.kt @@ -55,8 +55,7 @@ fun DroidconAppBarWithFilter( verticalAlignment = Alignment.CenterVertically ) { Image( - painter = if (isSystemInDarkTheme()) painterResource(id = R.drawable.droidcon_logo_dark) - else painterResource(id = R.drawable.droidcon_logo), + painter = painterResource(id = if (isSystemInDarkTheme()) R.drawable.droidcon_logo_dark else R.drawable.droidcon_logo), contentDescription = stringResource(id = R.string.logo) ) Spacer(modifier = Modifier.weight(1f)) From 5018874ac1cfda98ce069b2100e2ab6fe3b8ce84 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 7 Jun 2023 15:21:57 +0300 Subject: [PATCH 17/29] contrib-readme-action has updated readme (#127) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- README.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f1c9df2c..f4ed4b92 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,13 @@ We would endlessly like to thank the following contributors Null + + + Borwe +
+ Brian Orwe +
+ misshannah @@ -199,15 +206,15 @@ We would endlessly like to thank the following contributors
Yves Kalume
- + + mog-rn
Amos Nyaburi
- - + joelmuraguri @@ -242,15 +249,15 @@ We would endlessly like to thank the following contributors
Beatrice Kinya
- + + Dbriane208
Null
- - + Jacquigee From e7d01a9308f6725ac15fe65c69e168249df8c462 Mon Sep 17 00:00:00 2001 From: Harun Wangereka Date: Thu, 8 Jun 2023 12:55:01 +0300 Subject: [PATCH 18/29] Update to AS Flamingo (#126) * updates * Update AS Version * Dependencies updates * update readme * Update README.md * Update README.md * Update README.md * Update README.md * rename * update java version on CI --- .github/workflows/branch.yml | 9 +-- .github/workflows/deploy-to-playstore.yml | 10 +-- .github/workflows/main.yml | 9 +-- Gemfile | 3 + README.md | 62 ++++++++++++++---- .../gradle/wrapper/gradle-wrapper.properties | 2 +- build.gradle.kts | 12 ++-- chai/build.gradle.kts | 2 +- gradle.properties | 4 +- gradle/libs.versions.toml | 6 +- gradle/wrapper/gradle-wrapper.properties | 2 +- java_version.png | Bin 0 -> 103581 bytes presentation/build.gradle.kts | 2 +- 13 files changed, 82 insertions(+), 41 deletions(-) create mode 100644 java_version.png diff --git a/.github/workflows/branch.yml b/.github/workflows/branch.yml index 297e4456..1490df15 100644 --- a/.github/workflows/branch.yml +++ b/.github/workflows/branch.yml @@ -18,12 +18,13 @@ jobs: run: echo $AUTH | base64 --decode > /tmp/keystore - name: Checkout the Repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - - name: Set up JDK 11 - uses: actions/setup-java@v1 + - name: Set up JDK 17 + uses: actions/setup-java@v3 with: - java-version: '11' + distribution: 'zulu' + java-version: '17' - name: Cache gradle uses: actions/cache@v1 diff --git a/.github/workflows/deploy-to-playstore.yml b/.github/workflows/deploy-to-playstore.yml index 2e7ec394..0b7d28d6 100644 --- a/.github/workflows/deploy-to-playstore.yml +++ b/.github/workflows/deploy-to-playstore.yml @@ -9,12 +9,12 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: set up JDK 11 - uses: actions/setup-java@v1 + - uses: actions/checkout@v3 + - name: set up JDK 17 + uses: actions/setup-java@v3 with: - distribution: 'adopt' - java-version: '11' + distribution: 'zulu' + java-version: '17' - name: Assemble Release Bundle run: ./gradlew bundleRelease diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b67a18a9..697bb56a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -18,12 +18,13 @@ jobs: run: echo $AUTH | base64 --decode > /tmp/keystore - name: Checkout the Repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - - name: Set up JDK 11 - uses: actions/setup-java@v1 + - name: Set up JDK 17 + uses: actions/setup-java@v3 with: - java-version: '11' + distribution: 'zulu' + java-version: '17' - name: Cache gradle uses: actions/cache@v1 diff --git a/Gemfile b/Gemfile index 7a118b49..cdd3a6b3 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,6 @@ source "https://rubygems.org" gem "fastlane" + +plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') +eval_gemfile(plugins_path) if File.exist?(plugins_path) diff --git a/README.md b/README.md index f4ed4b92..17f84e6e 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,33 @@ # droidcon KE 23 🔥🔨 -Android app for the 4th Android Developer conference- droidcon to be held in Nairobi on 8th - 10th November. +Android app for the 4th Android Developer conference- droidcon to be held in Nairobi on 8th - 10th +November. This project is the Android app for the conference. The app supports devices running Android 5.0+, and is optimized for phones and tablets of all shapes and sizes. ## Running the Project -To ensure the project runs on your local environment ensure to you have Java 11 on your pc or if you don't have you can install it from [here](https://www.oracle.com/java/technologies/javase/jdk11-archive-downloads.html). +To ensure the project runs on your local environment ensure to you have Java 11 on your pc or if you +don't have you can install it +from [here](https://www.oracle.com/java/technologies/javase/jdk11-archive-downloads.html). -If you have multiple installations of Java make sure to set Java 11 as your preferred version to run the project. +If you have multiple installations of Java make sure to set Java 11 as your preferred version to run +the project. + +With the new Android Gradle Plugin version 8.0.0, you need Java 17 to run the project and any +terminal commands. A workaround for this, it to add this in your **global** gradle.properties file: + +```properties +org.gradle.java.home=/Applications/Android Studio.app/Contents/jbr/Contents/Home +``` + +This simply sets the Gradle Java home to the one set in Android Studio. Android Studio +Flamingo comes bundled with Java 17. You only need to ensure the project uses Java 17. To do so, +go to **File -> Project Structure -> SDK Location -> Gradle Settings** and set the Java Version to +17 from the list that appears. + +![image](java_version.png) ## Dependencies @@ -69,23 +87,32 @@ This is the link to the app designs: [Light Theme] (https://xd.adobe.com/view/dd5d0245-b92b-4678-9d4a-48b3a6f48191-880e/) [Dark Theme] (https://xd.adobe.com/view/5ec235b6-c3c6-49a9-b783-1f1303deb1a8-0b91/) -The app uses a design system: Chai +The app uses a design system: Chai ## Dependencies -The project uses [Versions Catalog](https://docs.gradle.org/current/userguide/platforms.html#sub:version-catalog) to set up and share dependencies across the modules. The main reasons for choosing to adopt Versions Catalog are: +The project +uses [Versions Catalog](https://docs.gradle.org/current/userguide/platforms.html#sub:version-catalog) +to set up and share dependencies across the modules. The main reasons for choosing to adopt Versions +Catalog are: + - Central place to define dependencies. - Easy syntax. - Does not compromise on build speeds as changes do not need the module to be compiled. -To add a dependency, navigate to **gradle/libs.versions.toml*** file, which has all the dependencies for the whole project. This file has the following sections: +To add a dependency, navigate to **gradle/libs.versions.toml*** file, which has all the dependencies +for the whole project. This file has the following sections: -[versions] is used to declare the version numbers that will be referenced later by plugins and libraries. +[versions] is used to declare the version numbers that will be referenced later by plugins and +libraries. [libraries] Define the libraries that will be later accessed in our Gradle files. -[bundles] Are used to define a set of dependencies. For this, we have `compose`, `room`, `lifecycle` and `ktor` as examples. +[bundles] Are used to define a set of dependencies. For this, we have `compose`, `room`, `lifecycle` +and `ktor` as examples. [plugins] Used to define plugins. -You need to add your dependency version in [versions]. This is unnecessary if you are not sharing the version across different dependencies. After defining the version, add your library in the [libraries] section as: +You need to add your dependency version in [versions]. This is unnecessary if you are not sharing +the version across different dependencies. After defining the version, add your library in +the [libraries] section as: ```toml compose-activity = "androidx.activity:activity-compose:1.5.0" @@ -98,16 +125,23 @@ androidx-splashscreen = { module = "androidx.core:core-splashscreen", version.re ``` **Note**: -- You can use separators such as -, _v, . that will be normalized by Gradle to . in the Catalog and allow you to create subsections. + +- You can use separators such as -, _v, . that will be normalized by Gradle to . in the Catalog and + allow you to create subsections. - Define variables using **CamelCase**.\ - Check if the library can be added to any existing bundles. ## Compatibility -This project uses `coreLibraryDesugaring` to support newer Java 8 APIs that are not available on API levels 25 and below. Specifically the Kotlin `kotlinx.datetime` API which depends on Java's `java.time`. -This allows use of `kotlinx.datetime.LocalDate` without having to wrap it in `@RequiresAPI(Build.VERSION_CODES.O)` and also backports the fix to API level 21. -More on Desugaring using Android Gradle Plugin can be found [here](https://developer.android.com/studio/write/java8-support). -Instructions on how to set up and add `coreLibraryDesugaring` to your project can be found [here](https://developer.android.com/studio/write/java8-support#library-desugaring). +This project uses `coreLibraryDesugaring` to support newer Java 8 APIs that are not available on API +levels 25 and below. Specifically the Kotlin `kotlinx.datetime` API which depends on +Java's `java.time`. +This allows use of `kotlinx.datetime.LocalDate` without having to wrap it +in `@RequiresAPI(Build.VERSION_CODES.O)` and also backports the fix to API level 21. +More on Desugaring using Android Gradle Plugin can be +found [here](https://developer.android.com/studio/write/java8-support). +Instructions on how to set up and add `coreLibraryDesugaring` to your project can be +found [here](https://developer.android.com/studio/write/java8-support#library-desugaring). ## Contributing diff --git a/build-logic/gradle/wrapper/gradle-wrapper.properties b/build-logic/gradle/wrapper/gradle-wrapper.properties index 2325a91d..664ef3d3 100644 --- a/build-logic/gradle/wrapper/gradle-wrapper.properties +++ b/build-logic/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Sat Feb 11 12:19:01 EAT 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-rc-2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/build.gradle.kts b/build.gradle.kts index 441c8f30..450bc886 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,16 +2,16 @@ buildscript { dependencies { classpath("com.google.dagger:hilt-android-gradle-plugin:2.45") - classpath("com.google.gms:google-services:4.3.14") - classpath("com.google.firebase:firebase-crashlytics-gradle:2.9.2") + classpath("com.google.gms:google-services:4.3.15") + classpath("com.google.firebase:firebase-crashlytics-gradle:2.9.5") classpath("com.google.firebase:perf-plugin:1.4.2") } } plugins { - id("com.android.application") version "7.4.1" apply false - id("com.android.library") version "7.4.1" apply false - id("org.jetbrains.kotlin.android") version "1.8.0" apply false - id("com.google.devtools.ksp") version "1.8.0-1.0.9" apply true + id("com.android.application") version "8.0.2" apply false + id("com.android.library") version "8.0.2" apply false + id("org.jetbrains.kotlin.android") version "1.8.20" apply false + id("com.google.devtools.ksp") version "1.8.20-1.0.11" apply true kotlin("plugin.serialization") version "1.6.21" id("org.jlleitschuh.gradle.ktlint") version "11.3.2" id("io.gitlab.arturbosch.detekt") version "1.18.0-RC2" diff --git a/chai/build.gradle.kts b/chai/build.gradle.kts index d595f0df..4ea37d76 100644 --- a/chai/build.gradle.kts +++ b/chai/build.gradle.kts @@ -74,7 +74,7 @@ android { } composeOptions { - kotlinCompilerExtensionVersion = "1.4.0" + kotlinCompilerExtensionVersion = "1.4.6" } } diff --git a/gradle.properties b/gradle.properties index 3c5031eb..a2e90d87 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,4 +20,6 @@ kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true \ No newline at end of file +android.nonTransitiveRClass=true +android.defaults.buildfeatures.buildconfig=true +android.nonFinalResIds=false \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c2e39fda..8979d522 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,12 +1,12 @@ [versions] compose = "1.4.0-beta01" -composecompiler = "1.3.1" +composecompiler = "1.4.6" coroutines = "1.6.4" desugar_jdk_libs = "2.0.3" espresso = "3.5.1" -gradleplugin = "7.4.1" +gradleplugin = "8.0.2" hilt = "2.45" -kotlin = "1.8.0" +kotlin = "1.8.20" lifecycle = "2.5.1" room = "2.5.0" ktor = "2.2.2" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 2325a91d..664ef3d3 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Sat Feb 11 12:19:01 EAT 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-rc-2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/java_version.png b/java_version.png new file mode 100644 index 0000000000000000000000000000000000000000..3fa5a6fffd1a06682b32e7b187aac9bac167105f GIT binary patch literal 103581 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
dSAVeHrQUq#*Y3Rka%ao)x2b`Y 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
vS$!F< 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

CLxkMTg;jip+Ns$%H%G~Qa6m8I+Mm4-n{ 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 Tnh1e9241oclqB=+>HGc Date: Thu, 8 Jun 2023 12:56:07 +0300 Subject: [PATCH 19/29] contrib-readme-action has updated readme (#130) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 17f84e6e..02d47cd8 100644 --- a/README.md +++ b/README.md @@ -171,17 +171,17 @@ We would endlessly like to thank the following contributors - - KennethMathari + + wangerekaharun
- Kenneth Mathari + Harun Wangereka
- - wangerekaharun + + KennethMathari
- Harun Wangereka + Kenneth Mathari
From e735b19c310bfc72dad1e5c10f291db65b62181d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 8 Jun 2023 12:57:10 +0300 Subject: [PATCH 20/29] contrib-readme-action has updated readme (#131) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 02d47cd8..17f84e6e 100644 --- a/README.md +++ b/README.md @@ -171,17 +171,17 @@ We would endlessly like to thank the following contributors - - wangerekaharun + + KennethMathari
- Harun Wangereka + Kenneth Mathari
- - KennethMathari + + wangerekaharun
- Kenneth Mathari + Harun Wangereka
From eb9a23480a739868a4a2b68543135a05116752f5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 8 Jun 2023 12:57:59 +0300 Subject: [PATCH 21/29] contrib-readme-action has updated readme (#132) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 17f84e6e..02d47cd8 100644 --- a/README.md +++ b/README.md @@ -171,17 +171,17 @@ We would endlessly like to thank the following contributors - - KennethMathari + + wangerekaharun
- Kenneth Mathari + Harun Wangereka
- - wangerekaharun + + KennethMathari
- Harun Wangereka + Kenneth Mathari
From f61f68c1d65f7dc1dddcc0cbb6ed15f5ec8ce818 Mon Sep 17 00:00:00 2001 From: yveskalume Date: Wed, 7 Jun 2023 13:55:22 +0200 Subject: [PATCH 22/29] Migrate some State and viewModel calls from their composables to their respective ViewModels --- .../presentation/about/view/AboutScreen.kt | 15 ++----- .../presentation/about/view/AboutViewModel.kt | 44 +++++++++++++------ .../presentation/home/screen/HomeScreen.kt | 5 --- .../home/viewmodel/HomeViewModel.kt | 6 ++- .../speakers/SpeakersScreenViewModel.kt | 10 ++++- .../speakers/view/SpeakersScreen.kt | 5 --- 6 files changed, 49 insertions(+), 36 deletions(-) diff --git a/presentation/src/main/java/com/android254/presentation/about/view/AboutScreen.kt b/presentation/src/main/java/com/android254/presentation/about/view/AboutScreen.kt index d88128d8..6137149a 100644 --- a/presentation/src/main/java/com/android254/presentation/about/view/AboutScreen.kt +++ b/presentation/src/main/java/com/android254/presentation/about/view/AboutScreen.kt @@ -26,9 +26,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext @@ -41,6 +39,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.compose.AsyncImage import coil.request.ImageRequest import com.android254.presentation.common.components.DroidconAppBarWithFeedbackButton @@ -61,14 +60,8 @@ fun AboutScreen( aboutViewModel: AboutViewModel = hiltViewModel(), navigateToFeedbackScreen: () -> Unit = {} ) { - val teamMembers = remember { mutableStateListOf() } - val stakeHolderLogos = remember { mutableStateListOf() } - - LaunchedEffect(Unit) { - val data = aboutViewModel.getOrganizers() - teamMembers.addAll(data.first) - stakeHolderLogos.addAll(data.second) - } + val teamMembers by aboutViewModel.teamMembers.collectAsStateWithLifecycle() + val stakeHolderLogos by aboutViewModel.stakeHolderLogos.collectAsStateWithLifecycle() Scaffold( topBar = { diff --git a/presentation/src/main/java/com/android254/presentation/about/view/AboutViewModel.kt b/presentation/src/main/java/com/android254/presentation/about/view/AboutViewModel.kt index 1a2464e2..b4c6f4e9 100644 --- a/presentation/src/main/java/com/android254/presentation/about/view/AboutViewModel.kt +++ b/presentation/src/main/java/com/android254/presentation/about/view/AboutViewModel.kt @@ -16,9 +16,13 @@ package com.android254.presentation.about.view import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.android254.domain.repos.OrganizersRepository import com.android254.presentation.models.OrganizingTeamMember import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel @@ -26,19 +30,33 @@ class AboutViewModel @Inject constructor( private val organizersRepo: OrganizersRepository ) : ViewModel() { - suspend fun getOrganizers(): Pair, List> { - val result = organizersRepo.getOrganizers() - val team = result.filter { it.type == "individual" }.map { - OrganizingTeamMember( - name = it.name, - desc = it.tagline, - image = it.photo - ) - } - val stakeholders = result.filterNot { it.type == "individual" }.map { - it.photo - } + private val _teamMembers: MutableStateFlow> = MutableStateFlow(emptyList()) + val teamMembers: StateFlow> + get() = _teamMembers + + private val _stakeHolderLogos: MutableStateFlow> = MutableStateFlow(emptyList()) + val stakeHolderLogos: StateFlow> + get() = _stakeHolderLogos - return Pair(team, stakeholders) + init { + getOrganizers() + } + + private fun getOrganizers() { + viewModelScope.launch { + val result = organizersRepo.getOrganizers() + val team = result.filter { it.type == "individual" }.map { + OrganizingTeamMember( + name = it.name, + desc = it.tagline, + image = it.photo + ) + } + val stakeholders = result.filterNot { it.type == "individual" }.map { + it.photo + } + _teamMembers.emit(team) + _stakeHolderLogos.emit(stakeholders) + } } } \ No newline at end of file diff --git a/presentation/src/main/java/com/android254/presentation/home/screen/HomeScreen.kt b/presentation/src/main/java/com/android254/presentation/home/screen/HomeScreen.kt index 32b20606..c6cbbf0e 100644 --- a/presentation/src/main/java/com/android254/presentation/home/screen/HomeScreen.kt +++ b/presentation/src/main/java/com/android254/presentation/home/screen/HomeScreen.kt @@ -23,7 +23,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag @@ -57,10 +56,6 @@ fun HomeScreen( ) { val homeViewState = homeViewModel.viewState - LaunchedEffect(key1 = Unit) { - homeViewModel.onGetHomeScreenDetails() - } - Scaffold( topBar = { HomeToolbar( diff --git a/presentation/src/main/java/com/android254/presentation/home/viewmodel/HomeViewModel.kt b/presentation/src/main/java/com/android254/presentation/home/viewmodel/HomeViewModel.kt index 7a8a2b62..56e1e50b 100644 --- a/presentation/src/main/java/com/android254/presentation/home/viewmodel/HomeViewModel.kt +++ b/presentation/src/main/java/com/android254/presentation/home/viewmodel/HomeViewModel.kt @@ -41,7 +41,11 @@ class HomeViewModel @Inject constructor( var viewState by mutableStateOf(HomeViewState()) private set - fun onGetHomeScreenDetails() { + init { + onGetHomeScreenDetails() + } + + private fun onGetHomeScreenDetails() { viewModelScope.launch { with(homeRepo.fetchHomeDetails()) { viewState = viewState.copy( diff --git a/presentation/src/main/java/com/android254/presentation/speakers/SpeakersScreenViewModel.kt b/presentation/src/main/java/com/android254/presentation/speakers/SpeakersScreenViewModel.kt index 9ecb6c29..093de0d3 100644 --- a/presentation/src/main/java/com/android254/presentation/speakers/SpeakersScreenViewModel.kt +++ b/presentation/src/main/java/com/android254/presentation/speakers/SpeakersScreenViewModel.kt @@ -16,12 +16,14 @@ package com.android254.presentation.speakers import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.android254.domain.models.ResourceResult import com.android254.domain.repos.SpeakersRepo import com.android254.presentation.models.SpeakerUI import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch import javax.inject.Inject sealed interface SpeakersScreenUiState { @@ -41,7 +43,13 @@ class SpeakersScreenViewModel @Inject constructor( private val _uiState = MutableStateFlow(SpeakersScreenUiState.Loading) val uiState = _uiState.asStateFlow() - suspend fun getSpeakers() { + init { + viewModelScope.launch { + getSpeakers() + } + } + + private suspend fun getSpeakers() { when (val result = speakersRepo.fetchSpeakers()) { is ResourceResult.Success -> { val speakers = result.data?.map { diff --git a/presentation/src/main/java/com/android254/presentation/speakers/view/SpeakersScreen.kt b/presentation/src/main/java/com/android254/presentation/speakers/view/SpeakersScreen.kt index af5a5f2f..b941695c 100644 --- a/presentation/src/main/java/com/android254/presentation/speakers/view/SpeakersScreen.kt +++ b/presentation/src/main/java/com/android254/presentation/speakers/view/SpeakersScreen.kt @@ -31,7 +31,6 @@ import androidx.compose.material3.SmallTopAppBar import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -64,10 +63,6 @@ fun SpeakersScreen( val context = LocalContext.current val uiState = speakersScreenViewModel.uiState.collectAsStateWithLifecycle().value - LaunchedEffect(key1 = true) { - speakersScreenViewModel.getSpeakers() - } - Scaffold( topBar = { SmallTopAppBar( From daeb947466ea0c98ce081d3ee518b6e11a246ee3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 8 Jun 2023 09:58:12 +0000 Subject: [PATCH 23/29] contrib-readme-action has updated readme --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 02d47cd8..17f84e6e 100644 --- a/README.md +++ b/README.md @@ -171,17 +171,17 @@ We would endlessly like to thank the following contributors - - wangerekaharun + + KennethMathari
- Harun Wangereka + Kenneth Mathari
- - KennethMathari + + wangerekaharun
- Kenneth Mathari + Harun Wangereka
From 685f9b1cfbe58f3461ebfd4ff06a619cb52a859e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 8 Jun 2023 16:21:55 +0300 Subject: [PATCH 24/29] contrib-readme-action has updated readme (#134) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- README.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 17f84e6e..62921da4 100644 --- a/README.md +++ b/README.md @@ -171,17 +171,17 @@ We would endlessly like to thank the following contributors - - KennethMathari + + wangerekaharun
- Kenneth Mathari + Harun Wangereka
- - wangerekaharun + + KennethMathari
- Harun Wangereka + Kenneth Mathari
@@ -220,6 +220,13 @@ We would endlessly like to thank the following contributors Brian Orwe + + + yveskalume +
+ Yves Kalume +
+ misshannah @@ -233,13 +240,6 @@ We would endlessly like to thank the following contributors
Kibet Theo
- - - - yveskalume -
- Yves Kalume -
From 3d1ec6f786fc2a8995e1ae3ffa0632a3a22bdcbc Mon Sep 17 00:00:00 2001 From: jacqui Date: Tue, 6 Jun 2023 22:31:07 +0300 Subject: [PATCH 25/29] Adds feed implementation --- .../common/bottomsheet/Strings.kt | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 presentation/src/main/java/com/android254/presentation/common/bottomsheet/Strings.kt diff --git a/presentation/src/main/java/com/android254/presentation/common/bottomsheet/Strings.kt b/presentation/src/main/java/com/android254/presentation/common/bottomsheet/Strings.kt new file mode 100644 index 00000000..103dcbdf --- /dev/null +++ b/presentation/src/main/java/com/android254/presentation/common/bottomsheet/Strings.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2023 DroidconKE + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android254.presentation.common.bottomsheet + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.R +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext + +@Immutable +@kotlin.jvm.JvmInline +value class Strings private constructor(@Suppress("unused") private val value: Int) { + companion object { + val NavigationMenu = Strings(0) + val CloseDrawer = Strings(1) + val CloseSheet = Strings(2) + val DefaultErrorMessage = Strings(3) + val ExposedDropdownMenu = Strings(4) + val SliderRangeStart = Strings(5) + val SliderRangeEnd = Strings(6) + } +} + +@Composable +fun getString(string: Strings): String { + LocalConfiguration.current + val resources = LocalContext.current.resources + return when (string) { + Strings.NavigationMenu -> resources.getString(R.string.navigation_menu) + Strings.CloseDrawer -> resources.getString(R.string.close_drawer) + Strings.CloseSheet -> resources.getString(R.string.close_sheet) + Strings.DefaultErrorMessage -> resources.getString(R.string.default_error_message) + Strings.ExposedDropdownMenu -> resources.getString(R.string.dropdown_menu) + Strings.SliderRangeStart -> resources.getString(R.string.range_start) + Strings.SliderRangeEnd -> resources.getString(R.string.range_end) + else -> "" + } +} \ No newline at end of file From a797ae9450b16aaeab0247a7fa7bfa6a19c4da86 Mon Sep 17 00:00:00 2001 From: jacqui Date: Fri, 9 Jun 2023 23:24:07 +0300 Subject: [PATCH 26/29] Handling state in the FeedScreen.kt file --- .../common/bottomsheet/BottomSheetScaffold.kt | 441 --------- .../presentation/common/bottomsheet/Drawer.kt | 691 -------------- .../common/bottomsheet/Swipeable.kt | 886 ------------------ .../presentation/feed/view/FeedScreen.kt | 83 +- 4 files changed, 53 insertions(+), 2048 deletions(-) delete mode 100644 presentation/src/main/java/com/android254/presentation/common/bottomsheet/BottomSheetScaffold.kt delete mode 100644 presentation/src/main/java/com/android254/presentation/common/bottomsheet/Drawer.kt delete mode 100644 presentation/src/main/java/com/android254/presentation/common/bottomsheet/Swipeable.kt diff --git a/presentation/src/main/java/com/android254/presentation/common/bottomsheet/BottomSheetScaffold.kt b/presentation/src/main/java/com/android254/presentation/common/bottomsheet/BottomSheetScaffold.kt deleted file mode 100644 index f38121a8..00000000 --- a/presentation/src/main/java/com/android254/presentation/common/bottomsheet/BottomSheetScaffold.kt +++ /dev/null @@ -1,441 +0,0 @@ -/* - * Copyright 2023 DroidconKE - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android254.presentation.common.bottomsheet - -import androidx.compose.animation.core.AnimationSpec -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.requiredHeightIn -import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.semantics.collapse -import androidx.compose.ui.semantics.expand -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.launch -import kotlin.math.roundToInt - -/** - * Possible values of [BottomSheetState]. - */ -enum class BottomSheetValue { - /** - * The bottom sheet is visible, but only showing its peek height. - */ - Collapsed, - - /** - * The bottom sheet is visible at its maximum height. - */ - Expanded -} - -/** - * State of the persistent bottom sheet in [BottomSheetScaffold]. - * - * @param initialValue The initial value of the state. - * @param animationSpec The default animation that will be used to animate to a new state. - * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Stable -class BottomSheetState( - initialValue: BottomSheetValue, - animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, - confirmStateChange: (BottomSheetValue) -> Boolean = { true } -) : SwipeableState( - initialValue = initialValue, - animationSpec = animationSpec, - confirmStateChange = confirmStateChange -) { - /** - * Whether the bottom sheet is expanded. - */ - val isExpanded: Boolean - get() = currentValue == BottomSheetValue.Expanded - - /** - * Whether the bottom sheet is collapsed. - */ - val isCollapsed: Boolean - get() = currentValue == BottomSheetValue.Collapsed - - /** - * Expand the bottom sheet with animation and suspend until it if fully expanded or animation - * has been cancelled. This method will throw [CancellationException] if the animation is - * interrupted - * - * @return the reason the expand animation ended - */ - suspend fun expand() = animateTo(BottomSheetValue.Expanded) - - /** - * Collapse the bottom sheet with animation and suspend until it if fully collapsed or animation - * has been cancelled. This method will throw [CancellationException] if the animation is - * interrupted - * - * @return the reason the collapse animation ended - */ - suspend fun collapse() = animateTo(BottomSheetValue.Collapsed) - - companion object { - /** - * The default [Saver] implementation for [BottomSheetState]. - */ - fun Saver( - animationSpec: AnimationSpec, - confirmStateChange: (BottomSheetValue) -> Boolean - ): Saver = Saver( - save = { it.currentValue }, - restore = { - BottomSheetState( - initialValue = it, - animationSpec = animationSpec, - confirmStateChange = confirmStateChange - ) - } - ) - } - - internal val nestedScrollConnection = this.PreUpPostDownNestedScrollConnection -} - -/** - * Create a [BottomSheetState] and [remember] it. - * - * @param initialValue The initial value of the state. - * @param animationSpec The default animation that will be used to animate to a new state. - * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change. - */ -@Composable -fun rememberBottomSheetState( - initialValue: BottomSheetValue, - animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, - confirmStateChange: (BottomSheetValue) -> Boolean = { true } -): BottomSheetState { - return rememberSaveable( - animationSpec, - saver = BottomSheetState.Saver( - animationSpec = animationSpec, - confirmStateChange = confirmStateChange - ) - ) { - BottomSheetState( - initialValue = initialValue, - animationSpec = animationSpec, - confirmStateChange = confirmStateChange - ) - } -} - -/** - * State of the [BottomSheetScaffold] composable. - * - * @param drawerState The state of the navigation drawer. - * @param bottomSheetState The state of the persistent bottom sheet. - * @param snackbarHostState The [SnackbarHostState] used to show snackbars inside the scaffold. - */ -@Stable -class BottomSheetScaffoldState( - val drawerState: DrawerState, - val bottomSheetState: BottomSheetState, - val snackbarHostState: SnackbarHostState -) - -/** - * Create and [remember] a [BottomSheetScaffoldState]. - * - * @param drawerState The state of the navigation drawer. - * @param bottomSheetState The state of the persistent bottom sheet. - * @param snackbarHostState The [SnackbarHostState] used to show snackbars inside the scaffold. - */ -@Composable -fun rememberBottomSheetScaffoldState( - drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed), - bottomSheetState: BottomSheetState = rememberBottomSheetState(BottomSheetValue.Collapsed), - snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } -): BottomSheetScaffoldState { - return remember(drawerState, bottomSheetState, snackbarHostState) { - BottomSheetScaffoldState( - drawerState = drawerState, - bottomSheetState = bottomSheetState, - snackbarHostState = snackbarHostState - ) - } -} - -/** - * Material Design standard bottom sheet. - * - * Standard bottom sheets co-exist with the screen’s main UI region and allow for simultaneously - * viewing and interacting with both regions. They are commonly used to keep a feature or - * secondary content visible on screen when content in main UI region is frequently scrolled or - * panned. - * - * ![Standard bottom sheet image](https://developer.android.com/images/reference/androidx/compose/material/standard-bottom-sheet.png) - * - * This component provides an API to put together several material components to construct your - * screen. For a similar component which implements the basic material design layout strategy - * with app bars, floating action buttons and navigation drawers, use the standard [Scaffold]. - * For similar component that uses a backdrop as the centerpiece of the screen, use - * [BackdropScaffold]. - * - * A simple example of a bottom sheet scaffold looks like this: - * - * @sample androidx.compose.material.samples.BottomSheetScaffoldSample - * - * @param sheetContent The content of the bottom sheet. - * @param modifier An optional [Modifier] for the root of the scaffold. - * @param scaffoldState The state of the scaffold. - * @param topBar An optional top app bar. - * @param snackbarHost The composable hosting the snackbars shown inside the scaffold. - * @param floatingActionButton An optional floating action button. - * @param floatingActionButtonPosition The position of the floating action button. - * @param sheetGesturesEnabled Whether the bottom sheet can be interacted with by gestures. - * @param sheetShape The shape of the bottom sheet. - * @param sheetElevation The elevation of the bottom sheet. - * @param sheetBackgroundColor The background color of the bottom sheet. - * @param sheetContentColor The preferred content color provided by the bottom sheet to its - * children. Defaults to the matching content color for [sheetBackgroundColor], or if that is - * not a color from the theme, this will keep the same content color set above the bottom sheet. - * @param sheetPeekHeight The height of the bottom sheet when it is collapsed. - * @param drawerContent The content of the drawer sheet. - * @param drawerGesturesEnabled Whether the drawer sheet can be interacted with by gestures. - * @param drawerShape The shape of the drawer sheet. - * @param drawerElevation The elevation of the drawer sheet. - * @param drawerBackgroundColor The background color of the drawer sheet. - * @param drawerContentColor The preferred content color provided by the drawer sheet to its - * children. Defaults to the matching content color for [drawerBackgroundColor], or if that is - * not a color from the theme, this will keep the same content color set above the drawer sheet. - * @param drawerScrimColor The color of the scrim that is applied when the drawer is open. - * @param content The main content of the screen. You should use the provided [PaddingValues] - * to properly offset the content, so that it is not obstructed by the bottom sheet when collapsed. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun BottomSheetScaffold( - sheetContent: @Composable ColumnScope.() -> Unit, - modifier: Modifier = Modifier, - scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(), - topBar: (@Composable () -> Unit)? = null, - snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) }, - floatingActionButton: (@Composable () -> Unit)? = null, - floatingActionButtonPosition: FabPosition = FabPosition.End, - sheetGesturesEnabled: Boolean = true, - sheetShape: Shape = MaterialTheme.shapes.large, - sheetElevation: Dp = BottomSheetScaffoldDefaults.SheetElevation, - sheetBackgroundColor: Color = MaterialTheme.colorScheme.surface, - sheetContentColor: Color = contentColorFor(sheetBackgroundColor), - sheetPeekHeight: Dp = BottomSheetScaffoldDefaults.SheetPeekHeight, - drawerContent: @Composable (ColumnScope.() -> Unit)? = null, - drawerGesturesEnabled: Boolean = true, - drawerShape: Shape = MaterialTheme.shapes.large, - drawerElevation: Dp = DrawerDefaults.Elevation, - drawerBackgroundColor: Color = MaterialTheme.colorScheme.surface, - drawerContentColor: Color = contentColorFor(drawerBackgroundColor), - drawerScrimColor: Color = DrawerDefaults.scrimColor, - backgroundColor: Color = MaterialTheme.colorScheme.background, - contentColor: Color = contentColorFor(backgroundColor), - content: @Composable (PaddingValues) -> Unit -) { - val scope = rememberCoroutineScope() - BoxWithConstraints(modifier) { - val fullHeight = constraints.maxHeight.toFloat() - val peekHeightPx = with(LocalDensity.current) { sheetPeekHeight.toPx() } - var bottomSheetHeight by remember { mutableStateOf(fullHeight) } - - val swipeable = Modifier - .nestedScroll(scaffoldState.bottomSheetState.nestedScrollConnection) - .swipeable( - state = scaffoldState.bottomSheetState, - anchors = mapOf( - fullHeight - peekHeightPx to BottomSheetValue.Collapsed, - fullHeight - bottomSheetHeight to BottomSheetValue.Expanded - ), - orientation = Orientation.Vertical, - enabled = sheetGesturesEnabled, - resistance = null - ) - .semantics { - if (peekHeightPx != bottomSheetHeight) { - if (scaffoldState.bottomSheetState.isCollapsed) { - expand { - if (scaffoldState.bottomSheetState.confirmStateChange(BottomSheetValue.Expanded)) { - scope.launch { scaffoldState.bottomSheetState.expand() } - } - true - } - } else { - collapse { - if (scaffoldState.bottomSheetState.confirmStateChange(BottomSheetValue.Collapsed)) { - scope.launch { scaffoldState.bottomSheetState.collapse() } - } - true - } - } - } - } - - val child = @Composable { - BottomSheetScaffoldStack( - body = { - Surface( - color = backgroundColor, - contentColor = contentColor - ) { - Column(Modifier.fillMaxSize()) { - topBar?.invoke() - content(PaddingValues(bottom = sheetPeekHeight)) - } - } - }, - bottomSheet = { - Surface( - swipeable - .fillMaxWidth() - .requiredHeightIn(min = sheetPeekHeight) - .onGloballyPositioned { - bottomSheetHeight = it.size.height.toFloat() - }, - shape = sheetShape, - // parameter does not exists in material3 - // elevation = sheetElevation, - color = sheetBackgroundColor, - contentColor = sheetContentColor, - content = { Column(content = sheetContent) } - ) - }, - floatingActionButton = { - Box { - floatingActionButton?.invoke() - } - }, - snackbarHost = { - Box { - snackbarHost(scaffoldState.snackbarHostState) - } - }, - bottomSheetOffset = scaffoldState.bottomSheetState.offset, - floatingActionButtonPosition = floatingActionButtonPosition - ) - } - if (drawerContent == null) { - child() - } else { - ModalDrawer( - drawerContent = drawerContent, - drawerState = scaffoldState.drawerState, - gesturesEnabled = drawerGesturesEnabled, - drawerShape = drawerShape, - drawerElevation = drawerElevation, - drawerBackgroundColor = drawerBackgroundColor, - drawerContentColor = drawerContentColor, - scrimColor = drawerScrimColor, - content = child - ) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun BottomSheetScaffoldStack( - body: @Composable () -> Unit, - bottomSheet: @Composable () -> Unit, - floatingActionButton: @Composable () -> Unit, - snackbarHost: @Composable () -> Unit, - bottomSheetOffset: State, - floatingActionButtonPosition: FabPosition -) { - Layout( - content = { - body() - bottomSheet() - floatingActionButton() - snackbarHost() - } - ) { measurables, constraints -> - val placeable = measurables.first().measure(constraints) - - layout(placeable.width, placeable.height) { - placeable.placeRelative(0, 0) - - val (sheetPlaceable, fabPlaceable, snackbarPlaceable) = - measurables.drop(1).map { - it.measure(constraints.copy(minWidth = 0, minHeight = 0)) - } - - val sheetOffsetY = bottomSheetOffset.value.roundToInt() - - sheetPlaceable.placeRelative(0, sheetOffsetY) - - val fabOffsetX = when (floatingActionButtonPosition) { - FabPosition.Center -> (placeable.width - fabPlaceable.width) / 2 - else -> placeable.width - fabPlaceable.width - FabEndSpacing.roundToPx() - } - val fabOffsetY = sheetOffsetY - fabPlaceable.height / 2 - - fabPlaceable.placeRelative(fabOffsetX, fabOffsetY) - - val snackbarOffsetX = (placeable.width - snackbarPlaceable.width) / 2 - val snackbarOffsetY = placeable.height - snackbarPlaceable.height - - snackbarPlaceable.placeRelative(snackbarOffsetX, snackbarOffsetY) - } - } -} - -private val FabEndSpacing = 16.dp - -/** - * Contains useful defaults for [BottomSheetScaffold]. - */ -object BottomSheetScaffoldDefaults { - - /** - * The default elevation used by [BottomSheetScaffold]. - */ - val SheetElevation = 8.dp - - /** - * The default peek height used by [BottomSheetScaffold]. - */ - val SheetPeekHeight = 56.dp -} \ No newline at end of file diff --git a/presentation/src/main/java/com/android254/presentation/common/bottomsheet/Drawer.kt b/presentation/src/main/java/com/android254/presentation/common/bottomsheet/Drawer.kt deleted file mode 100644 index 8e50361c..00000000 --- a/presentation/src/main/java/com/android254/presentation/common/bottomsheet/Drawer.kt +++ /dev/null @@ -1,691 +0,0 @@ -/* - * Copyright 2023 DroidconKE - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android254.presentation.common.bottomsheet - -import androidx.compose.animation.core.AnimationSpec -import androidx.compose.animation.core.TweenSpec -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.sizeIn -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.contentColorFor -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.R -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.isSpecified -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.dismiss -import androidx.compose.ui.semantics.onClick -import androidx.compose.ui.semantics.paneTitle -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.launch -import kotlin.math.max -import kotlin.math.roundToInt -/** - * Possible values of [DrawerState]. - */ -enum class DrawerValue { - /** - * The state of the drawer when it is closed. - */ - Closed, - - /** - * The state of the drawer when it is open. - */ - Open -} - -/** - * Possible values of [BottomDrawerState]. - */ -enum class BottomDrawerValue { - /** - * The state of the bottom drawer when it is closed. - */ - Closed, - - /** - * The state of the bottom drawer when it is open (i.e. at 50% height). - */ - Open, - - /** - * The state of the bottom drawer when it is expanded (i.e. at 100% height). - */ - Expanded -} - -/** - * State of the [ModalDrawer] composable. - * - * @param initialValue The initial value of the state. - * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Suppress("NotCloseable") -@Stable -class DrawerState( - initialValue: DrawerValue, - confirmStateChange: (DrawerValue) -> Boolean = { true } -) { - - internal val swipeableState = SwipeableState( - initialValue = initialValue, - animationSpec = AnimationSpec, - confirmStateChange = confirmStateChange - ) - - /** - * Whether the drawer is open. - */ - val isOpen: Boolean - get() = currentValue == DrawerValue.Open - - /** - * Whether the drawer is closed. - */ - val isClosed: Boolean - get() = currentValue == DrawerValue.Closed - - /** - * The current value of the state. - * - * If no swipe or animation is in progress, this corresponds to the start the drawer - * currently in. If a swipe or an animation is in progress, this corresponds the state drawer - * was in before the swipe or animation started. - */ - val currentValue: DrawerValue - get() { - return swipeableState.currentValue - } - - /** - * Whether the state is currently animating. - */ - val isAnimationRunning: Boolean - get() { - return swipeableState.isAnimationRunning - } - - /** - * Open the drawer with animation and suspend until it if fully opened or animation has been - * cancelled. This method will throw [CancellationException] if the animation is - * interrupted - * - * @return the reason the open animation ended - */ - suspend fun open() = animateTo(DrawerValue.Open, AnimationSpec) - - /** - * Close the drawer with animation and suspend until it if fully closed or animation has been - * cancelled. This method will throw [CancellationException] if the animation is - * interrupted - * - * @return the reason the close animation ended - */ - suspend fun close() = animateTo(DrawerValue.Closed, AnimationSpec) - - /** - * Set the state of the drawer with specific animation - * - * @param targetValue The new value to animate to. - * @param anim The animation that will be used to animate to the new value. - */ - suspend fun animateTo(targetValue: DrawerValue, anim: AnimationSpec) { - swipeableState.animateTo(targetValue, anim) - } - - /** - * Set the state without any animation and suspend until it's set - * - * @param targetValue The new target value - */ - suspend fun snapTo(targetValue: DrawerValue) { - swipeableState.snapTo(targetValue) - } - - /** - * The target value of the drawer state. - * - * If a swipe is in progress, this is the value that the Drawer would animate to if the - * swipe finishes. If an animation is running, this is the target value of that animation. - * Finally, if no swipe or animation is in progress, this is the same as the [currentValue]. - */ - @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET") - val targetValue: DrawerValue - get() = swipeableState.targetValue - - /** - * The current position (in pixels) of the drawer sheet. - */ - @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET") - val offset: State - get() = swipeableState.offset - - companion object { - /** - * The default [Saver] implementation for [DrawerState]. - */ - fun Saver(confirmStateChange: (DrawerValue) -> Boolean) = - Saver( - save = { it.currentValue }, - restore = { DrawerState(it, confirmStateChange) } - ) - } -} - -/** - * State of the [BottomDrawer] composable. - * - * @param initialValue The initial value of the state. - * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Suppress("NotCloseable") -class BottomDrawerState( - initialValue: BottomDrawerValue, - confirmStateChange: (BottomDrawerValue) -> Boolean = { true } -) : SwipeableState( - initialValue = initialValue, - animationSpec = AnimationSpec, - confirmStateChange = confirmStateChange -) { - /** - * Whether the drawer is open, either in opened or expanded state. - */ - val isOpen: Boolean - get() = currentValue != BottomDrawerValue.Closed - - /** - * Whether the drawer is closed. - */ - val isClosed: Boolean - get() = currentValue == BottomDrawerValue.Closed - - /** - * Whether the drawer is expanded. - */ - val isExpanded: Boolean - get() = currentValue == BottomDrawerValue.Expanded - - /** - * Open the drawer with animation and suspend until it if fully opened or animation has been - * cancelled. If the content height is less than [BottomDrawerOpenFraction], the drawer state - * will move to [BottomDrawerValue.Expanded] instead. - * - * @throws [CancellationException] if the animation is interrupted - * - */ - suspend fun open() { - val targetValue = - if (isOpenEnabled) BottomDrawerValue.Open else BottomDrawerValue.Expanded - animateTo(targetValue) - } - - /** - * Close the drawer with animation and suspend until it if fully closed or animation has been - * cancelled. - * - * @throws [CancellationException] if the animation is interrupted - * - */ - suspend fun close() = animateTo(BottomDrawerValue.Closed) - - /** - * Expand the drawer with animation and suspend until it if fully expanded or animation has - * been cancelled. - * - * @throws [CancellationException] if the animation is interrupted - * - */ - suspend fun expand() = animateTo(BottomDrawerValue.Expanded) - - private val isOpenEnabled: Boolean - get() = anchors.values.contains(BottomDrawerValue.Open) - - internal val nestedScrollConnection = this.PreUpPostDownNestedScrollConnection - - companion object { - /** - * The default [Saver] implementation for [BottomDrawerState]. - */ - fun Saver(confirmStateChange: (BottomDrawerValue) -> Boolean) = - Saver( - save = { it.currentValue }, - restore = { BottomDrawerState(it, confirmStateChange) } - ) - } -} - -/** - * Create and [remember] a [DrawerState]. - * - * @param initialValue The initial value of the state. - * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change. - */ -@Composable -fun rememberDrawerState( - initialValue: DrawerValue, - confirmStateChange: (DrawerValue) -> Boolean = { true } -): DrawerState { - return rememberSaveable(saver = DrawerState.Saver(confirmStateChange)) { - DrawerState(initialValue, confirmStateChange) - } -} - -/** - * Create and [remember] a [BottomDrawerState]. - * - * @param initialValue The initial value of the state. - * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change. - */ -@Composable -fun rememberBottomDrawerState( - initialValue: BottomDrawerValue, - confirmStateChange: (BottomDrawerValue) -> Boolean = { true } -): BottomDrawerState { - return rememberSaveable(saver = BottomDrawerState.Saver(confirmStateChange)) { - BottomDrawerState(initialValue, confirmStateChange) - } -} - -/** - * Material Design modal navigation drawer. - * - * Modal navigation drawers block interaction with the rest of an app’s content with a scrim. - * They are elevated above most of the app’s UI and don’t affect the screen’s layout grid. - * - * ![Modal drawer image](https://developer.android.com/images/reference/androidx/compose/material/modal-drawer.png) - * - * See [BottomDrawer] for a layout that introduces a bottom drawer, suitable when - * using bottom navigation. - * - * @sample androidx.compose.material.samples.ModalDrawerSample - * - * @param drawerContent composable that represents content inside the drawer - * @param modifier optional modifier for the drawer - * @param drawerState state of the drawer - * @param gesturesEnabled whether or not drawer can be interacted by gestures - * @param drawerShape shape of the drawer sheet - * @param drawerElevation drawer sheet elevation. This controls the size of the shadow below the - * drawer sheet - * @param drawerBackgroundColor background color to be used for the drawer sheet - * @param drawerContentColor color of the content to use inside the drawer sheet. Defaults to - * either the matching content color for [drawerBackgroundColor], or, if it is not a color from - * the theme, this will keep the same value set above this Surface. - * @param scrimColor color of the scrim that obscures content when the drawer is open - * @param content content of the rest of the UI - * - * @throws IllegalStateException when parent has [Float.POSITIVE_INFINITY] width - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ModalDrawer( - drawerContent: @Composable ColumnScope.() -> Unit, - modifier: Modifier = Modifier, - drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed), - gesturesEnabled: Boolean = true, - drawerShape: Shape = MaterialTheme.shapes.large, - drawerElevation: Dp = DrawerDefaults.Elevation, - drawerBackgroundColor: Color = MaterialTheme.colorScheme.surface, - drawerContentColor: Color = contentColorFor(drawerBackgroundColor), - scrimColor: Color = DrawerDefaults.scrimColor, - content: @Composable () -> Unit -) { - val scope = rememberCoroutineScope() - BoxWithConstraints(modifier.fillMaxSize()) { - val modalDrawerConstraints = constraints - // TODO : think about Infinite max bounds case - if (!modalDrawerConstraints.hasBoundedWidth) { - throw IllegalStateException("Drawer shouldn't have infinite width") - } - - val minValue = -modalDrawerConstraints.maxWidth.toFloat() - val maxValue = 0f - - val anchors = mapOf(minValue to DrawerValue.Closed, maxValue to DrawerValue.Open) - val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl - Box( - Modifier.swipeable( - state = drawerState.swipeableState, - anchors = anchors, - thresholds = { _, _ -> FractionalThreshold(0.5f) }, - orientation = Orientation.Horizontal, - enabled = gesturesEnabled, - reverseDirection = isRtl, - velocityThreshold = DrawerVelocityThreshold, - resistance = null - ) - ) { - Box { - content() - } - Scrim( - open = drawerState.isOpen, - onClose = { - if ( - gesturesEnabled && - drawerState.swipeableState.confirmStateChange(DrawerValue.Closed) - ) { - scope.launch { drawerState.close() } - } - }, - fraction = { - calculateFraction(minValue, maxValue, drawerState.offset.value) - }, - color = scrimColor - ) - val resources = LocalContext.current.resources - val navigationMenu = resources.getString(R.string.navigation_menu) - Surface( - modifier = with(LocalDensity.current) { - Modifier - .sizeIn( - minWidth = modalDrawerConstraints.minWidth.toDp(), - minHeight = modalDrawerConstraints.minHeight.toDp(), - maxWidth = modalDrawerConstraints.maxWidth.toDp(), - maxHeight = modalDrawerConstraints.maxHeight.toDp() - ) - } - .offset { IntOffset(drawerState.offset.value.roundToInt(), 0) } - .padding(end = EndDrawerPadding) - .semantics { - paneTitle = navigationMenu - if (drawerState.isOpen) { - dismiss { - if ( - drawerState.swipeableState - .confirmStateChange(DrawerValue.Closed) - ) { - scope.launch { drawerState.close() } - }; true - } - } - }, - shape = drawerShape, - color = drawerBackgroundColor, - contentColor = drawerContentColor - // does not exist in material3 - // elevation = drawerElevation - ) { - Column(Modifier.fillMaxSize(), content = drawerContent) - } - } - } -} - -/** - * Material Design bottom navigation drawer. - * - * Bottom navigation drawers are modal drawers that are anchored to the bottom of the screen instead - * of the left or right edge. They are only used with bottom app bars. - * - * ![Bottom drawer image](https://developer.android.com/images/reference/androidx/compose/material/bottom-drawer.png) - * - * See [ModalDrawer] for a layout that introduces a classic from-the-side drawer. - * - * @sample androidx.compose.material.samples.BottomDrawerSample - * - * @param drawerState state of the drawer - * @param modifier optional [Modifier] for the entire component - * @param gesturesEnabled whether or not drawer can be interacted by gestures - * @param drawerShape shape of the drawer sheet - * @param drawerElevation drawer sheet elevation. This controls the size of the shadow below the - * drawer sheet - * @param drawerContent composable that represents content inside the drawer - * @param drawerBackgroundColor background color to be used for the drawer sheet - * @param drawerContentColor color of the content to use inside the drawer sheet. Defaults to - * either the matching content color for [drawerBackgroundColor], or, if it is not a color from - * the theme, this will keep the same value set above this Surface. - * @param scrimColor color of the scrim that obscures content when the drawer is open. If the - * color passed is [Color.Unspecified], then a scrim will no longer be applied and the bottom - * drawer will not block interaction with the rest of the screen when visible. - * @param content content of the rest of the UI - * - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun BottomDrawer( - drawerContent: @Composable ColumnScope.() -> Unit, - modifier: Modifier = Modifier, - drawerState: BottomDrawerState = rememberBottomDrawerState(BottomDrawerValue.Closed), - gesturesEnabled: Boolean = true, - drawerShape: Shape = MaterialTheme.shapes.large, - drawerElevation: Dp = DrawerDefaults.Elevation, - drawerBackgroundColor: Color = MaterialTheme.colorScheme.surface, - drawerContentColor: Color = contentColorFor(drawerBackgroundColor), - scrimColor: Color = DrawerDefaults.scrimColor, - content: @Composable () -> Unit -) { - val scope = rememberCoroutineScope() - - BoxWithConstraints(modifier.fillMaxSize()) { - val fullHeight = constraints.maxHeight.toFloat() - var drawerHeight by remember(fullHeight) { mutableStateOf(fullHeight) } - // TODO(b/178630869) Proper landscape support - val isLandscape = constraints.maxWidth > constraints.maxHeight - - val minHeight = 0f - val peekHeight = fullHeight * BottomDrawerOpenFraction - val expandedHeight = max(minHeight, fullHeight - drawerHeight) - val anchors = if (drawerHeight < peekHeight || isLandscape) { - mapOf( - fullHeight to BottomDrawerValue.Closed, - expandedHeight to BottomDrawerValue.Expanded - ) - } else { - mapOf( - fullHeight to BottomDrawerValue.Closed, - peekHeight to BottomDrawerValue.Open, - expandedHeight to BottomDrawerValue.Expanded - ) - } - val drawerConstraints = with(LocalDensity.current) { - Modifier - .sizeIn( - maxWidth = constraints.maxWidth.toDp(), - maxHeight = constraints.maxHeight.toDp() - ) - } - val nestedScroll = if (gesturesEnabled) { - Modifier.nestedScroll(drawerState.nestedScrollConnection) - } else { - Modifier - } - val swipeable = Modifier - .then(nestedScroll) - .swipeable( - state = drawerState, - anchors = anchors, - orientation = Orientation.Vertical, - enabled = gesturesEnabled, - resistance = null - ) - - Box(swipeable) { - content() - BottomDrawerScrim( - color = scrimColor, - onDismiss = { - if ( - gesturesEnabled && drawerState.confirmStateChange(BottomDrawerValue.Closed) - ) { - scope.launch { drawerState.close() } - } - }, - visible = drawerState.targetValue != BottomDrawerValue.Closed - ) - val resources = LocalContext.current.resources - val navigationMenu = resources.getString(R.string.navigation_menu) - Surface( - drawerConstraints - .offset { IntOffset(x = 0, y = drawerState.offset.value.roundToInt()) } - .onGloballyPositioned { position -> - drawerHeight = position.size.height.toFloat() - } - .semantics { - paneTitle = navigationMenu - if (drawerState.isOpen) { - // TODO(b/180101663) The action currently doesn't return the correct results - dismiss { - if (drawerState.confirmStateChange(BottomDrawerValue.Closed)) { - scope.launch { drawerState.close() } - }; true - } - } - }, - shape = drawerShape, - color = drawerBackgroundColor, - contentColor = drawerContentColor - // parameter does not exist in material3 - // elevation = drawerElevation - ) { - Column(content = drawerContent) - } - } - } -} - -/** - * Object to hold default values for [ModalDrawer] and [BottomDrawer] - */ -object DrawerDefaults { - - /** - * Default Elevation for drawer sheet as specified in material specs - */ - val Elevation = 16.dp - - val scrimColor: Color - @Composable - get() = MaterialTheme.colorScheme.onSurface.copy(alpha = ScrimOpacity) - - /** - * Default alpha for scrim color - */ - const val ScrimOpacity = 0.32f -} - -private fun calculateFraction(a: Float, b: Float, pos: Float) = - ((pos - a) / (b - a)).coerceIn(0f, 1f) - -@Composable -private fun BottomDrawerScrim( - color: Color, - onDismiss: () -> Unit, - visible: Boolean -) { - if (color.isSpecified) { - val alpha by animateFloatAsState( - targetValue = if (visible) 1f else 0f, - animationSpec = TweenSpec() - ) - val resources = LocalContext.current.resources - val closeDrawer = resources.getString(R.string.close_drawer) - val dismissModifier = if (visible) { - Modifier - .pointerInput(onDismiss) { - detectTapGestures { onDismiss() } - } - .semantics(mergeDescendants = true) { - contentDescription = closeDrawer - onClick { onDismiss(); true } - } - } else { - Modifier - } - - Canvas( - Modifier - .fillMaxSize() - .then(dismissModifier) - ) { - drawRect(color = color, alpha = alpha) - } - } -} - -@Composable -private fun Scrim( - open: Boolean, - onClose: () -> Unit, - fraction: () -> Float, - color: Color -) { - val resources = LocalContext.current.resources - val closeDrawer = resources.getString(R.string.close_drawer) - val dismissDrawer = if (open) { - Modifier - .pointerInput(onClose) { detectTapGestures { onClose() } } - .semantics(mergeDescendants = true) { - contentDescription = closeDrawer - onClick { onClose(); true } - } - } else { - Modifier - } - - Canvas( - Modifier - .fillMaxSize() - .then(dismissDrawer) - ) { - drawRect(color, alpha = fraction()) - } -} - -private val EndDrawerPadding = 56.dp -private val DrawerVelocityThreshold = 400.dp - -// TODO: b/177571613 this should be a proper decay settling -// this is taken from the DrawerLayout's DragViewHelper as a min duration. -private val AnimationSpec = TweenSpec(durationMillis = 256) - -private const val BottomDrawerOpenFraction = 0.5f \ No newline at end of file diff --git a/presentation/src/main/java/com/android254/presentation/common/bottomsheet/Swipeable.kt b/presentation/src/main/java/com/android254/presentation/common/bottomsheet/Swipeable.kt deleted file mode 100644 index e125107a..00000000 --- a/presentation/src/main/java/com/android254/presentation/common/bottomsheet/Swipeable.kt +++ /dev/null @@ -1,886 +0,0 @@ -/* - * Copyright 2023 DroidconKE - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android254.presentation.common.bottomsheet - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.AnimationSpec -import androidx.compose.animation.core.SpringSpec -import androidx.compose.foundation.gestures.DraggableState -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Stable -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Modifier -import androidx.compose.ui.composed -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.debugInspectorInfo -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.Velocity -import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.lerp -import com.android254.presentation.common.bottomsheet.SwipeableDefaults.AnimationSpec -import com.android254.presentation.common.bottomsheet.SwipeableDefaults.StandardResistanceFactor -import com.android254.presentation.common.bottomsheet.SwipeableDefaults.VelocityThreshold -import com.android254.presentation.common.bottomsheet.SwipeableDefaults.resistanceConfig -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.launch -import kotlin.math.PI -import kotlin.math.abs -import kotlin.math.sign -import kotlin.math.sin - -/** - * State of the [swipeable] modifier. - * - * This contains necessary information about any ongoing swipe or animation and provides methods - * to change the state either immediately or by starting an animation. To create and remember a - * [SwipeableState] with the default animation clock, use [rememberSwipeableState]. - * - * @param initialValue The initial value of the state. - * @param animationSpec The default animation that will be used to animate to a new state. - * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change. - */ -@Stable -@ExperimentalMaterial3Api -open class SwipeableState( - initialValue: T, - internal val animationSpec: AnimationSpec = AnimationSpec, - internal val confirmStateChange: (newValue: T) -> Boolean = { true } -) { - /** - * The current value of the state. - * - * If no swipe or animation is in progress, this corresponds to the anchor at which the - * [swipeable] is currently settled. If a swipe or animation is in progress, this corresponds - * the last anchor at which the [swipeable] was settled before the swipe or animation started. - */ - var currentValue: T by mutableStateOf(initialValue) - private set - - /** - * Whether the state is currently animating. - */ - var isAnimationRunning: Boolean by mutableStateOf(false) - private set - - /** - * The current position (in pixels) of the [swipeable]. - * - * You should use this state to offset your content accordingly. The recommended way is to - * use `Modifier.offsetPx`. This includes the resistance by default, if resistance is enabled. - */ - val offset: State get() = offsetState - - /** - * The amount by which the [swipeable] has been swiped past its bounds. - */ - val overflow: State get() = overflowState - - // Use `Float.NaN` as a placeholder while the state is uninitialised. - private val offsetState = mutableStateOf(0f) - private val overflowState = mutableStateOf(0f) - - // the source of truth for the "real"(non ui) position - // basically position in bounds + overflow - private val absoluteOffset = mutableStateOf(0f) - - // current animation target, if animating, otherwise null - private val animationTarget = mutableStateOf(null) - - internal var anchors by mutableStateOf(emptyMap()) - - private val latestNonEmptyAnchorsFlow: Flow> = - snapshotFlow { anchors } - .filter { it.isNotEmpty() } - .take(1) - - internal var minBound = Float.NEGATIVE_INFINITY - internal var maxBound = Float.POSITIVE_INFINITY - - internal fun ensureInit(newAnchors: Map) { - if (anchors.isEmpty()) { - // need to do initial synchronization synchronously :( - val initialOffset = newAnchors.getOffset(currentValue) - requireNotNull(initialOffset) { - "The initial value must have an associated anchor." - } - offsetState.value = initialOffset - absoluteOffset.value = initialOffset - } - } - - internal suspend fun processNewAnchors( - oldAnchors: Map, - newAnchors: Map - ) { - if (oldAnchors.isEmpty()) { - // If this is the first time that we receive anchors, then we need to initialise - // the state so we snap to the offset associated to the initial value. - minBound = newAnchors.keys.minOrNull()!! - maxBound = newAnchors.keys.maxOrNull()!! - val initialOffset = newAnchors.getOffset(currentValue) - requireNotNull(initialOffset) { - "The initial value must have an associated anchor." - } - snapInternalToOffset(initialOffset) - } else if (newAnchors != oldAnchors) { - // If we have received new anchors, then the offset of the current value might - // have changed, so we need to animate to the new offset. If the current value - // has been removed from the anchors then we animate to the closest anchor - // instead. Note that this stops any ongoing animation. - minBound = Float.NEGATIVE_INFINITY - maxBound = Float.POSITIVE_INFINITY - val animationTargetValue = animationTarget.value - // if we're in the animation already, let's find it a new home - val targetOffset = if (animationTargetValue != null) { - // first, try to map old state to the new state - val oldState = oldAnchors[animationTargetValue] - val newState = newAnchors.getOffset(oldState) - // return new state if exists, or find the closes one among new anchors - newState ?: newAnchors.keys.minByOrNull { abs(it - animationTargetValue) }!! - } else { - // we're not animating, proceed by finding the new anchors for an old value - val actualOldValue = oldAnchors[offset.value] - val value = if (actualOldValue == currentValue) currentValue else actualOldValue - newAnchors.getOffset(value) ?: newAnchors - .keys.minByOrNull { abs(it - offset.value) }!! - } - try { - animateInternalToOffset(targetOffset, animationSpec) - } catch (c: CancellationException) { - // If the animation was interrupted for any reason, snap as a last resort. - snapInternalToOffset(targetOffset) - } finally { - currentValue = newAnchors.getValue(targetOffset) - minBound = newAnchors.keys.minOrNull()!! - maxBound = newAnchors.keys.maxOrNull()!! - } - } - } - - internal var thresholds: (Float, Float) -> Float by mutableStateOf({ _, _ -> 0f }) - - internal var velocityThreshold by mutableStateOf(0f) - - internal var resistance: ResistanceConfig? by mutableStateOf(null) - - internal val draggableState = DraggableState { - val newAbsolute = absoluteOffset.value + it - val clamped = newAbsolute.coerceIn(minBound, maxBound) - val overflow = newAbsolute - clamped - val resistanceDelta = resistance?.computeResistance(overflow) ?: 0f - offsetState.value = clamped + resistanceDelta - overflowState.value = overflow - absoluteOffset.value = newAbsolute - } - - private suspend fun snapInternalToOffset(target: Float) { - draggableState.drag { - dragBy(target - absoluteOffset.value) - } - } - - private suspend fun animateInternalToOffset(target: Float, spec: AnimationSpec) { - draggableState.drag { - var prevValue = absoluteOffset.value - animationTarget.value = target - isAnimationRunning = true - try { - Animatable(prevValue).animateTo(target, spec) { - dragBy(this.value - prevValue) - prevValue = this.value - } - } finally { - animationTarget.value = null - isAnimationRunning = false - } - } - } - - /** - * The target value of the state. - * - * If a swipe is in progress, this is the value that the [swipeable] would animate to if the - * swipe finished. If an animation is running, this is the target value of that animation. - * Finally, if no swipe or animation is in progress, this is the same as the [currentValue]. - */ - @ExperimentalMaterial3Api - internal val targetValue: T - get() { - // TODO(calintat): Track current velocity (b/149549482) and use that here. - val target = animationTarget.value ?: computeTarget( - offset = offset.value, - lastValue = anchors.getOffset(currentValue) ?: offset.value, - anchors = anchors.keys, - thresholds = thresholds, - velocity = 0f, - velocityThreshold = Float.POSITIVE_INFINITY - ) - return anchors[target] ?: currentValue - } - - /** - * Information about the ongoing swipe or animation, if any. See [SwipeProgress] for details. - * - * If no swipe or animation is in progress, this returns `SwipeProgress(value, value, 1f)`. - */ - @ExperimentalMaterial3Api - internal val progress: SwipeProgress - get() { - val bounds = findBounds(offset.value, anchors.keys) - val from: T - val to: T - val fraction: Float - when (bounds.size) { - 0 -> { - from = currentValue - to = currentValue - fraction = 1f - } - 1 -> { - from = anchors.getValue(bounds[0]) - to = anchors.getValue(bounds[0]) - fraction = 1f - } - else -> { - val (a, b) = - if (direction > 0f) { - bounds[0] to bounds[1] - } else { - bounds[1] to bounds[0] - } - from = anchors.getValue(a) - to = anchors.getValue(b) - fraction = (offset.value - a) / (b - a) - } - } - return SwipeProgress(from, to, fraction) - } - - /** - * The direction in which the [swipeable] is moving, relative to the current [currentValue]. - * - * This will be either 1f if it is is moving from left to right or top to bottom, -1f if it is - * moving from right to left or bottom to top, or 0f if no swipe or animation is in progress. - */ - @ExperimentalMaterial3Api - internal val direction: Float - get() = anchors.getOffset(currentValue)?.let { sign(offset.value - it) } ?: 0f - - /** - * Set the state without any animation and suspend until it's set - * - * @param targetValue The new target value to set [currentValue] to. - */ - @ExperimentalMaterial3Api - internal suspend fun snapTo(targetValue: T) { - latestNonEmptyAnchorsFlow.collect { anchors -> - val targetOffset = anchors.getOffset(targetValue) - requireNotNull(targetOffset) { - "The target value must have an associated anchor." - } - snapInternalToOffset(targetOffset) - currentValue = targetValue - } - } - - /** - * Set the state to the target value by starting an animation. - * - * @param targetValue The new value to animate to. - * @param anim The animation that will be used to animate to the new value. - */ - @ExperimentalMaterial3Api - internal suspend fun animateTo(targetValue: T, anim: AnimationSpec = animationSpec) { - latestNonEmptyAnchorsFlow.collect { anchors -> - try { - val targetOffset = anchors.getOffset(targetValue) - requireNotNull(targetOffset) { - "The target value must have an associated anchor." - } - animateInternalToOffset(targetOffset, anim) - } finally { - val endOffset = absoluteOffset.value - val endValue = anchors - // fighting rounding error once again, anchor should be as close as 0.5 pixels - .filterKeys { anchorOffset -> abs(anchorOffset - endOffset) < 0.5f } - .values.firstOrNull() ?: currentValue - currentValue = endValue - } - } - } - - /** - * Perform fling with settling to one of the anchors which is determined by the given - * [velocity]. Fling with settling [swipeable] will always consume all the velocity provided - * since it will settle at the anchor. - * - * In general cases, [swipeable] flings by itself when being swiped. This method is to be - * used for nested scroll logic that wraps the [swipeable]. In nested scroll developer may - * want to trigger settling fling when the child scroll container reaches the bound. - * - * @param velocity velocity to fling and settle with - * - * @return the reason fling ended - */ - internal suspend fun performFling(velocity: Float) { - latestNonEmptyAnchorsFlow.collect { anchors -> - val lastAnchor = anchors.getOffset(currentValue)!! - val targetValue = computeTarget( - offset = offset.value, - lastValue = lastAnchor, - anchors = anchors.keys, - thresholds = thresholds, - velocity = velocity, - velocityThreshold = velocityThreshold - ) - val targetState = anchors[targetValue] - if (targetState != null && confirmStateChange(targetState)) { - animateTo(targetState) - } // If the user vetoed the state change, rollback to the previous state. - else { - animateInternalToOffset(lastAnchor, animationSpec) - } - } - } - - /** - * Force [swipeable] to consume drag delta provided from outside of the regular [swipeable] - * gesture flow. - * - * Note: This method performs generic drag and it won't settle to any particular anchor, * - * leaving swipeable in between anchors. When done dragging, [performFling] must be - * called as well to ensure swipeable will settle at the anchor. - * - * In general cases, [swipeable] drags by itself when being swiped. This method is to be - * used for nested scroll logic that wraps the [swipeable]. In nested scroll developer may - * want to force drag when the child scroll container reaches the bound. - * - * @param delta delta in pixels to drag by - * - * @return the amount of [delta] consumed - */ - internal fun performDrag(delta: Float): Float { - val potentiallyConsumed = absoluteOffset.value + delta - val clamped = potentiallyConsumed.coerceIn(minBound, maxBound) - val deltaToConsume = clamped - absoluteOffset.value - if (abs(deltaToConsume) > 0) { - draggableState.dispatchRawDelta(deltaToConsume) - } - return deltaToConsume - } - - companion object { - /** - * The default [Saver] implementation for [SwipeableState]. - */ - fun Saver( - animationSpec: AnimationSpec, - confirmStateChange: (T) -> Boolean - ) = Saver, T>( - save = { it.currentValue }, - restore = { SwipeableState(it, animationSpec, confirmStateChange) } - ) - } -} - -/** - * Collects information about the ongoing swipe or animation in [swipeable]. - * - * To access this information, use [SwipeableState.progress]. - * - * @param from The state corresponding to the anchor we are moving away from. - * @param to The state corresponding to the anchor we are moving towards. - * @param fraction The fraction that the current position represents between [from] and [to]. - * Must be between `0` and `1`. - */ -@Immutable -@ExperimentalMaterial3Api -internal class SwipeProgress( - val from: T, - val to: T, - /*@FloatRange(from = 0.0, to = 1.0)*/ - val fraction: Float -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is SwipeProgress<*>) return false - - if (from != other.from) return false - if (to != other.to) return false - if (fraction != other.fraction) return false - - return true - } - - override fun hashCode(): Int { - var result = from?.hashCode() ?: 0 - result = 31 * result + (to?.hashCode() ?: 0) - result = 31 * result + fraction.hashCode() - return result - } - - override fun toString(): String { - return "SwipeProgress(from=$from, to=$to, fraction=$fraction)" - } -} - -/** - * Create and [remember] a [SwipeableState] with the default animation clock. - * - * @param initialValue The initial value of the state. - * @param animationSpec The default animation that will be used to animate to a new state. - * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change. - */ -@Composable -@ExperimentalMaterial3Api -internal fun rememberSwipeableState( - initialValue: T, - animationSpec: AnimationSpec = AnimationSpec, - confirmStateChange: (newValue: T) -> Boolean = { true } -): SwipeableState { - return rememberSaveable( - saver = SwipeableState.Saver( - animationSpec = animationSpec, - confirmStateChange = confirmStateChange - ) - ) { - SwipeableState( - initialValue = initialValue, - animationSpec = animationSpec, - confirmStateChange = confirmStateChange - ) - } -} - -/** - * Create and [remember] a [SwipeableState] which is kept in sync with another state, i.e.: - * 1. Whenever the [value] changes, the [SwipeableState] will be animated to that new value. - * 2. Whenever the value of the [SwipeableState] changes (e.g. after a swipe), the owner of the - * [value] will be notified to update their state to the new value of the [SwipeableState] by - * invoking [onValueChange]. If the owner does not update their state to the provided value for - * some reason, then the [SwipeableState] will perform a rollback to the previous, correct value. - */ -@Composable -@ExperimentalMaterial3Api -internal fun rememberSwipeableStateFor( - value: T, - onValueChange: (T) -> Unit, - animationSpec: AnimationSpec = AnimationSpec -): SwipeableState { - val swipeableState = remember { - SwipeableState( - initialValue = value, - animationSpec = animationSpec, - confirmStateChange = { true } - ) - } - val forceAnimationCheck = remember { mutableStateOf(false) } - LaunchedEffect(value, forceAnimationCheck.value) { - if (value != swipeableState.currentValue) { - swipeableState.animateTo(value) - } - } - DisposableEffect(swipeableState.currentValue) { - if (value != swipeableState.currentValue) { - onValueChange(swipeableState.currentValue) - forceAnimationCheck.value = !forceAnimationCheck.value - } - onDispose { } - } - return swipeableState -} - -/** - * Enable swipe gestures between a set of predefined states. - * - * To use this, you must provide a map of anchors (in pixels) to states (of type [T]). - * Note that this map cannot be empty and cannot have two anchors mapped to the same state. - * - * When a swipe is detected, the offset of the [SwipeableState] will be updated with the swipe - * delta. You should use this offset to move your content accordingly (see `Modifier.offsetPx`). - * When the swipe ends, the offset will be animated to one of the anchors and when that anchor is - * reached, the value of the [SwipeableState] will also be updated to the state corresponding to - * the new anchor. The target anchor is calculated based on the provided positional [thresholds]. - * - * Swiping is constrained between the minimum and maximum anchors. If the user attempts to swipe - * past these bounds, a resistance effect will be applied by default. The amount of resistance at - * each edge is specified by the [resistance] config. To disable all resistance, set it to `null`. - * - * @param T The type of the state. - * @param state The state of the [swipeable]. - * @param anchors Pairs of anchors and states, used to map anchors to states and vice versa. - * @param thresholds Specifies where the thresholds between the states are. The thresholds will be - * used to determine which state to animate to when swiping stops. This is represented as a lambda - * that takes two states and returns the threshold between them in the form of a [ThresholdConfig]. - * Note that the order of the states corresponds to the swipe direction. - * @param orientation The orientation in which the [swipeable] can be swiped. - * @param enabled Whether this [swipeable] is enabled and should react to the user's input. - * @param reverseDirection Whether to reverse the direction of the swipe, so a top to bottom - * swipe will behave like bottom to top, and a left to right swipe will behave like right to left. - * @param interactionSource Optional [MutableInteractionSource] that will passed on to - * the internal [Modifier.draggable]. - * @param resistance Controls how much resistance will be applied when swiping past the bounds. - * @param velocityThreshold The threshold (in dp per second) that the end velocity has to exceed - * in order to animate to the next state, even if the positional [thresholds] have not been reached. - */ -@ExperimentalMaterial3Api -internal fun Modifier.swipeable( - state: SwipeableState, - anchors: Map, - orientation: Orientation, - enabled: Boolean = true, - reverseDirection: Boolean = false, - interactionSource: MutableInteractionSource? = null, - thresholds: (from: T, to: T) -> ThresholdConfig = { _, _ -> FixedThreshold(56.dp) }, - resistance: ResistanceConfig? = resistanceConfig(anchors.keys), - velocityThreshold: Dp = VelocityThreshold -) = composed( - inspectorInfo = debugInspectorInfo { - name = "swipeable" - properties["state"] = state - properties["anchors"] = anchors - properties["orientation"] = orientation - properties["enabled"] = enabled - properties["reverseDirection"] = reverseDirection - properties["interactionSource"] = interactionSource - properties["thresholds"] = thresholds - properties["resistance"] = resistance - properties["velocityThreshold"] = velocityThreshold - } -) { - require(anchors.isNotEmpty()) { - "You must have at least one anchor." - } - require(anchors.values.distinct().count() == anchors.size) { - "You cannot have two anchors mapped to the same state." - } - val density = LocalDensity.current - state.ensureInit(anchors) - LaunchedEffect(anchors, state) { - val oldAnchors = state.anchors - state.anchors = anchors - state.resistance = resistance - state.thresholds = { a, b -> - val from = anchors.getValue(a) - val to = anchors.getValue(b) - with(thresholds(from, to)) { density.computeThreshold(a, b) } - } - with(density) { - state.velocityThreshold = velocityThreshold.toPx() - } - state.processNewAnchors(oldAnchors, anchors) - } - - Modifier.draggable( - orientation = orientation, - enabled = enabled, - reverseDirection = reverseDirection, - interactionSource = interactionSource, - startDragImmediately = state.isAnimationRunning, - onDragStopped = { velocity -> launch { state.performFling(velocity) } }, - state = state.draggableState - ) -} - -/** - * Interface to compute a threshold between two anchors/states in a [swipeable]. - * - * To define a [ThresholdConfig], consider using [FixedThreshold] and [FractionalThreshold]. - */ -@Stable -@ExperimentalMaterial3Api -internal interface ThresholdConfig { - /** - * Compute the value of the threshold (in pixels), once the values of the anchors are known. - */ - fun Density.computeThreshold(fromValue: Float, toValue: Float): Float -} - -/** - * A fixed threshold will be at an [offset] away from the first anchor. - * - * @param offset The offset (in dp) that the threshold will be at. - */ -@Immutable -@ExperimentalMaterial3Api -internal data class FixedThreshold(private val offset: Dp) : ThresholdConfig { - override fun Density.computeThreshold(fromValue: Float, toValue: Float): Float { - return fromValue + offset.toPx() * sign(toValue - fromValue) - } -} - -/** - * A fractional threshold will be at a [fraction] of the way between the two anchors. - * - * @param fraction The fraction (between 0 and 1) that the threshold will be at. - */ -@Immutable @ExperimentalMaterial3Api -internal data class FractionalThreshold( - /*@FloatRange(from = 0.0, to = 1.0)*/ - private val fraction: Float -) : ThresholdConfig { - override fun Density.computeThreshold(fromValue: Float, toValue: Float): Float { - return lerp(fromValue, toValue, fraction) - } -} - -/** - * Specifies how resistance is calculated in [swipeable]. - * - * There are two things needed to calculate resistance: the resistance basis determines how much - * overflow will be consumed to achieve maximum resistance, and the resistance factor determines - * the amount of resistance (the larger the resistance factor, the stronger the resistance). - * - * The resistance basis is usually either the size of the component which [swipeable] is applied - * to, or the distance between the minimum and maximum anchors. For a constructor in which the - * resistance basis defaults to the latter, consider using [resistanceConfig]. - * - * You may specify different resistance factors for each bound. Consider using one of the default - * resistance factors in [SwipeableDefaults]: `StandardResistanceFactor` to convey that the user - * has run out of things to see, and `StiffResistanceFactor` to convey that the user cannot swipe - * this right now. Also, you can set either factor to 0 to disable resistance at that bound. - * - * @param basis Specifies the maximum amount of overflow that will be consumed. Must be positive. - * @param factorAtMin The factor by which to scale the resistance at the minimum bound. - * Must not be negative. - * @param factorAtMax The factor by which to scale the resistance at the maximum bound. - * Must not be negative. - */ -@Immutable -internal class ResistanceConfig( - /*@FloatRange(from = 0.0, fromInclusive = false)*/ - val basis: Float, - /*@FloatRange(from = 0.0)*/ - val factorAtMin: Float = StandardResistanceFactor, - /*@FloatRange(from = 0.0)*/ - val factorAtMax: Float = StandardResistanceFactor -) { - fun computeResistance(overflow: Float): Float { - val factor = if (overflow < 0) factorAtMin else factorAtMax - if (factor == 0f) return 0f - val progress = (overflow / basis).coerceIn(-1f, 1f) - return basis / factor * sin(progress * PI.toFloat() / 2) - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is ResistanceConfig) return false - - if (basis != other.basis) return false - if (factorAtMin != other.factorAtMin) return false - if (factorAtMax != other.factorAtMax) return false - - return true - } - - override fun hashCode(): Int { - var result = basis.hashCode() - result = 31 * result + factorAtMin.hashCode() - result = 31 * result + factorAtMax.hashCode() - return result - } - - override fun toString(): String { - return "ResistanceConfig(basis=$basis, factorAtMin=$factorAtMin, factorAtMax=$factorAtMax)" - } -} - -/** - * Given an offset x and a set of anchors, return a list of anchors: - * 1. [ ] if the set of anchors is empty, - * 2. [ x' ] if x is equal to one of the anchors, accounting for a small rounding error, where x' - * is x rounded to the exact value of the matching anchor, - * 3. [ min ] if min is the minimum anchor and x < min, - * 4. [ max ] if max is the maximum anchor and x > max, or - * 5. [ a , b ] if a and b are anchors such that a < x < b and b - a is minimal. - */ -private fun findBounds( - offset: Float, - anchors: Set -): List { - // Find the anchors the target lies between with a little bit of rounding error. - val a = anchors.filter { it <= offset + 0.001 }.maxOrNull() - val b = anchors.filter { it >= offset - 0.001 }.minOrNull() - - return when { - a == null -> - // case 1 or 3 - listOfNotNull(b) - b == null -> - // case 4 - listOf(a) - a == b -> - // case 2 - // Can't return offset itself here since it might not be exactly equal - // to the anchor, despite being considered an exact match. - listOf(a) - else -> - // case 5 - listOf(a, b) - } -} - -private fun computeTarget( - offset: Float, - lastValue: Float, - anchors: Set, - thresholds: (Float, Float) -> Float, - velocity: Float, - velocityThreshold: Float -): Float { - val bounds = findBounds(offset, anchors) - return when (bounds.size) { - 0 -> lastValue - 1 -> bounds[0] - else -> { - val lower = bounds[0] - val upper = bounds[1] - if (lastValue <= offset) { - // Swiping from lower to upper (positive). - if (velocity >= velocityThreshold) { - return upper - } else { - val threshold = thresholds(lower, upper) - if (offset < threshold) lower else upper - } - } else { - // Swiping from upper to lower (negative). - if (velocity <= -velocityThreshold) { - return lower - } else { - val threshold = thresholds(upper, lower) - if (offset > threshold) upper else lower - } - } - } - } -} - -private fun Map.getOffset(state: T): Float? { - return entries.firstOrNull { it.value == state }?.key -} - -/** - * Contains useful defaults for [swipeable] and [SwipeableState]. - */ -internal object SwipeableDefaults { - /** - * The default animation used by [SwipeableState]. - */ - internal val AnimationSpec = SpringSpec() - - /** - * The default velocity threshold (1.8 dp per millisecond) used by [swipeable]. - */ - internal val VelocityThreshold = 125.dp - - /** - * A stiff resistance factor which indicates that swiping isn't available right now. - */ - const val StiffResistanceFactor = 20f - - /** - * A standard resistance factor which indicates that the user has run out of things to see. - */ - const val StandardResistanceFactor = 10f - - /** - * The default resistance config used by [swipeable]. - * - * This returns `null` if there is one anchor. If there are at least two anchors, it returns - * a [ResistanceConfig] with the resistance basis equal to the distance between the two bounds. - */ - internal fun resistanceConfig( - anchors: Set, - factorAtMin: Float = StandardResistanceFactor, - factorAtMax: Float = StandardResistanceFactor - ): ResistanceConfig? { - return if (anchors.size <= 1) { - null - } else { - val basis = anchors.maxOrNull()!! - anchors.minOrNull()!! - ResistanceConfig(basis, factorAtMin, factorAtMax) - } - } -} - -// temp default nested scroll connection for swipeables which desire as an opt in -// revisit in b/174756744 as all types will have their own specific connection probably -@ExperimentalMaterial3Api -internal val SwipeableState.PreUpPostDownNestedScrollConnection: NestedScrollConnection - get() = object : NestedScrollConnection { - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - val delta = available.toFloat() - return if (delta < 0 && source == NestedScrollSource.Drag) { - performDrag(delta).toOffset() - } else { - Offset.Zero - } - } - - override fun onPostScroll( - consumed: Offset, - available: Offset, - source: NestedScrollSource - ): Offset { - return if (source == NestedScrollSource.Drag) { - performDrag(available.toFloat()).toOffset() - } else { - Offset.Zero - } - } - - override suspend fun onPreFling(available: Velocity): Velocity { - val toFling = Offset(available.x, available.y).toFloat() - return if (toFling < 0 && offset.value > minBound) { - performFling(velocity = toFling) - // since we go to the anchor with tween settling, consume all for the best UX - available - } else { - Velocity.Zero - } - } - - override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { - performFling(velocity = Offset(available.x, available.y).toFloat()) - return available - } - - private fun Float.toOffset(): Offset = Offset(0f, this) - - private fun Offset.toFloat(): Float = this.y - } \ No newline at end of file diff --git a/presentation/src/main/java/com/android254/presentation/feed/view/FeedScreen.kt b/presentation/src/main/java/com/android254/presentation/feed/view/FeedScreen.kt index 9a47388d..54821c02 100644 --- a/presentation/src/main/java/com/android254/presentation/feed/view/FeedScreen.kt +++ b/presentation/src/main/java/com/android254/presentation/feed/view/FeedScreen.kt @@ -16,26 +16,36 @@ package com.android254.presentation.feed.view import android.content.res.Configuration +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Error +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import com.android254.presentation.common.bottomsheet.BottomSheetScaffold -import com.android254.presentation.common.bottomsheet.rememberBottomSheetScaffoldState import com.android254.presentation.common.components.DroidconAppBarWithFeedbackButton import com.android254.presentation.feed.FeedViewModel import com.droidconke.chai.ChaiDCKE22Theme @@ -46,48 +56,61 @@ fun FeedScreen( navigateToFeedbackScreen: () -> Unit = {}, feedViewModel: FeedViewModel = hiltViewModel() ) { - val bottomSheetScaffoldState = rememberBottomSheetScaffoldState() + val bottomSheetState = rememberSheetState() val scope = rememberCoroutineScope() feedViewModel.fetchFeed() val feedUIState = feedViewModel.viewState - BottomSheetScaffold( - sheetContent = { - FeedShareSection() - }, - scaffoldState = bottomSheetScaffoldState, - topBar = { - DroidconAppBarWithFeedbackButton( - onButtonClick = { - navigateToFeedbackScreen() - }, - userProfile = "https://media-exp1.licdn.com/dms/image/C4D03AQGn58utIO-x3w/profile-displayphoto-shrink_200_200/0/1637478114039?e=2147483647&v=beta&t=3kIon0YJQNHZojD3Dt5HVODJqHsKdf2YKP1SfWeROnI" - ) - }, - sheetShape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), - sheetElevation = 16.dp, - sheetPeekHeight = 0.dp - ) { paddingValues -> + if (bottomSheetState.isVisible) ModalBottomSheet(sheetState = bottomSheetState, + onDismissRequest = { scope.launch { bottomSheetState.hide() } }) { + FeedShareSection() + } + Scaffold(topBar = { + DroidconAppBarWithFeedbackButton( + onButtonClick = { + navigateToFeedbackScreen() + }, + userProfile = "https://media-exp1.licdn.com/dms/image/C4D03AQGn58utIO-x3w/profile-displayphoto-shrink_200_200/0/1637478114039?e=2147483647&v=beta&t=3kIon0YJQNHZojD3Dt5HVODJqHsKdf2YKP1SfWeROnI" + ) + }) { paddingValues -> Box( modifier = Modifier - .padding(paddingValues) + .padding(paddingValues = paddingValues) .fillMaxSize() ) { when (feedUIState) { - is FeedUIState.Error -> {} - FeedUIState.Loading -> {} + is FeedUIState.Error -> { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + modifier = Modifier + .width(50.dp) + .height(50.dp), + imageVector = Icons.Rounded.Error, + contentDescription = "Error", + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.error) + ) + Text(text = feedUIState.message) + } + } + + FeedUIState.Loading -> { + CircularProgressIndicator() + } + is FeedUIState.Success -> { LazyColumn( modifier = Modifier.testTag("feeds_lazy_column"), verticalArrangement = Arrangement.spacedBy(4.dp) ) { items(feedUIState.feeds) { feedPresentationModel -> - FeedComponent(modifier = Modifier.fillMaxWidth(), feedPresentationModel) { + FeedComponent( + modifier = Modifier.fillMaxWidth(), feedPresentationModel + ) { scope.launch { - if (bottomSheetScaffoldState.bottomSheetState.isCollapsed) { - bottomSheetScaffoldState.bottomSheetState.expand() - } else { - bottomSheetScaffoldState.bottomSheetState.collapse() - } + bottomSheetState.show() } } } From fdbf6ed10a6ae3a6f76e76e757c50bc70f9a6844 Mon Sep 17 00:00:00 2001 From: jacqui Date: Thu, 15 Jun 2023 00:29:21 +0300 Subject: [PATCH 27/29] Refactoring results --- .../com/android254/data/repos/FeedManager.kt | 22 +++++-------------- .../com/android254/domain/repos/FeedRepo.kt | 3 ++- .../presentation/feed/FeedViewModel.kt | 8 +++++-- .../presentation/feed/view/FeedComponent.kt | 2 +- .../presentation/feed/view/FeedScreen.kt | 4 ++++ .../presentation/feed/view/FeedUIState.kt | 1 + 6 files changed, 20 insertions(+), 20 deletions(-) diff --git a/data/src/main/java/com/android254/data/repos/FeedManager.kt b/data/src/main/java/com/android254/data/repos/FeedManager.kt index ee3fac5a..40fbfcd8 100644 --- a/data/src/main/java/com/android254/data/repos/FeedManager.kt +++ b/data/src/main/java/com/android254/data/repos/FeedManager.kt @@ -19,29 +19,19 @@ import com.android254.data.network.apis.FeedApi import com.android254.data.repos.mappers.toDomain import com.android254.domain.models.DataResult import com.android254.domain.models.Feed +import com.android254.domain.models.ResourceResult import com.android254.domain.repos.FeedRepo import javax.inject.Inject class FeedManager @Inject constructor( private val FeedApi: FeedApi ) : FeedRepo { - override suspend fun fetchFeed(): List? { + override suspend fun fetchFeed(): ResourceResult> { return when (val result = FeedApi.fetchFeed(1, 100)) { - is DataResult.Empty -> { - emptyList() - } - - is DataResult.Error -> { - null - } - - is DataResult.Success -> { - val data = result.data - data.map { it.toDomain() } - } - else -> { - emptyList() - } + DataResult.Empty -> ResourceResult.Empty("Empty list ") + is DataResult.Error -> ResourceResult.Error(result.message) + is DataResult.Loading -> ResourceResult.Loading(true) + is DataResult.Success -> ResourceResult.Success(result.data.map { it.toDomain() }) } } } \ No newline at end of file diff --git a/domain/src/main/java/com/android254/domain/repos/FeedRepo.kt b/domain/src/main/java/com/android254/domain/repos/FeedRepo.kt index e22d35bc..78eb68c7 100644 --- a/domain/src/main/java/com/android254/domain/repos/FeedRepo.kt +++ b/domain/src/main/java/com/android254/domain/repos/FeedRepo.kt @@ -16,7 +16,8 @@ package com.android254.domain.repos import com.android254.domain.models.Feed +import com.android254.domain.models.ResourceResult interface FeedRepo { - suspend fun fetchFeed(): List? + suspend fun fetchFeed(): ResourceResult> } \ No newline at end of file diff --git a/presentation/src/main/java/com/android254/presentation/feed/FeedViewModel.kt b/presentation/src/main/java/com/android254/presentation/feed/FeedViewModel.kt index 0af14586..74182335 100644 --- a/presentation/src/main/java/com/android254/presentation/feed/FeedViewModel.kt +++ b/presentation/src/main/java/com/android254/presentation/feed/FeedViewModel.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.android254.domain.models.ResourceResult import com.android254.domain.repos.FeedRepo import com.android254.presentation.feed.view.FeedUIState import com.android254.presentation.feed.view.toPresentation @@ -37,8 +38,11 @@ class FeedViewModel @Inject constructor( fun fetchFeed() { viewModelScope.launch { viewState = when (val value = feedRepo.fetchFeed()) { - null -> FeedUIState.Error("Error getting result") - else -> FeedUIState.Success(value.map { it.toPresentation() }) + is ResourceResult.Empty -> FeedUIState.Empty + is ResourceResult.Error -> FeedUIState.Error(value.message) + is ResourceResult.Loading -> FeedUIState.Loading + is ResourceResult.Success -> FeedUIState.Success(value.data?.map { it.toPresentation() } + ?: emptyList()) } } } diff --git a/presentation/src/main/java/com/android254/presentation/feed/view/FeedComponent.kt b/presentation/src/main/java/com/android254/presentation/feed/view/FeedComponent.kt index 29df19b9..5303fbce 100644 --- a/presentation/src/main/java/com/android254/presentation/feed/view/FeedComponent.kt +++ b/presentation/src/main/java/com/android254/presentation/feed/view/FeedComponent.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 DroidconKE + * Copyright 2023 DroidconKE * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/presentation/src/main/java/com/android254/presentation/feed/view/FeedScreen.kt b/presentation/src/main/java/com/android254/presentation/feed/view/FeedScreen.kt index 54821c02..ef6b9cf0 100644 --- a/presentation/src/main/java/com/android254/presentation/feed/view/FeedScreen.kt +++ b/presentation/src/main/java/com/android254/presentation/feed/view/FeedScreen.kt @@ -116,6 +116,10 @@ fun FeedScreen( } } } + + FeedUIState.Empty -> Column(modifier = Modifier.fillMaxSize()) { + Text(text = "Empty") + } } } } diff --git a/presentation/src/main/java/com/android254/presentation/feed/view/FeedUIState.kt b/presentation/src/main/java/com/android254/presentation/feed/view/FeedUIState.kt index a75ab2d7..03f82a6d 100644 --- a/presentation/src/main/java/com/android254/presentation/feed/view/FeedUIState.kt +++ b/presentation/src/main/java/com/android254/presentation/feed/view/FeedUIState.kt @@ -19,6 +19,7 @@ import com.android254.presentation.models.FeedUI sealed interface FeedUIState { object Loading : FeedUIState + object Empty : FeedUIState data class Error(val message: String) : FeedUIState data class Success(val feeds: List) : FeedUIState } \ No newline at end of file From 6d8495e9c9a51e4ae37dd798c64125bc180e2080 Mon Sep 17 00:00:00 2001 From: jacqui Date: Thu, 15 Jun 2023 01:10:49 +0300 Subject: [PATCH 28/29] Fixes failing tests --- .../com/android254/presentation/feed/view/FeedScreenTest.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/presentation/src/test/java/com/android254/presentation/feed/view/FeedScreenTest.kt b/presentation/src/test/java/com/android254/presentation/feed/view/FeedScreenTest.kt index bd14a26d..a3d5b08b 100644 --- a/presentation/src/test/java/com/android254/presentation/feed/view/FeedScreenTest.kt +++ b/presentation/src/test/java/com/android254/presentation/feed/view/FeedScreenTest.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import com.android254.domain.models.Feed +import com.android254.domain.models.ResourceResult import com.android254.domain.repos.FeedRepo import com.android254.presentation.common.theme.DroidconKE2023Theme import com.android254.presentation.feed.FeedViewModel @@ -50,7 +51,7 @@ class FeedScreenTest { @Test fun `should display feed items`() { - coEvery { repo.fetchFeed() } returns listOf(Feed("", "", "", "", "", "")) + coEvery { repo.fetchFeed() } returns ResourceResult.Success(listOf(Feed("", "", "", "", "", ""))) composeTestRule.setContent { DroidconKE2023Theme { @@ -67,7 +68,7 @@ class FeedScreenTest { @Test fun `test share bottom sheet is shown`() { - coEvery { repo.fetchFeed() } returns listOf(Feed("", "", "", "", "", "")) + coEvery { repo.fetchFeed() } returns ResourceResult.Success(listOf(Feed("", "", "", "", "", ""))) composeTestRule.setContent { DroidconKE2023Theme { From 409fa1a67167d39ae970f821c1fbb333f06f7745 Mon Sep 17 00:00:00 2001 From: jacqui Date: Tue, 27 Jun 2023 22:09:22 +0300 Subject: [PATCH 29/29] Fixes String resource merge conflicts --- .../android254/presentation/feed/FeedViewModel.kt | 6 ++++-- .../android254/presentation/feed/view/FeedScreen.kt | 13 +++++++++---- .../presentation/feedback/view/FeedBackScreen.kt | 3 ++- presentation/src/main/res/values/strings.xml | 1 + 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/presentation/src/main/java/com/android254/presentation/feed/FeedViewModel.kt b/presentation/src/main/java/com/android254/presentation/feed/FeedViewModel.kt index 74182335..ccee2ca2 100644 --- a/presentation/src/main/java/com/android254/presentation/feed/FeedViewModel.kt +++ b/presentation/src/main/java/com/android254/presentation/feed/FeedViewModel.kt @@ -41,8 +41,10 @@ class FeedViewModel @Inject constructor( is ResourceResult.Empty -> FeedUIState.Empty is ResourceResult.Error -> FeedUIState.Error(value.message) is ResourceResult.Loading -> FeedUIState.Loading - is ResourceResult.Success -> FeedUIState.Success(value.data?.map { it.toPresentation() } - ?: emptyList()) + is ResourceResult.Success -> FeedUIState.Success( + value.data?.map { it.toPresentation() } + ?: emptyList() + ) } } } diff --git a/presentation/src/main/java/com/android254/presentation/feed/view/FeedScreen.kt b/presentation/src/main/java/com/android254/presentation/feed/view/FeedScreen.kt index ef6b9cf0..ee31faa3 100644 --- a/presentation/src/main/java/com/android254/presentation/feed/view/FeedScreen.kt +++ b/presentation/src/main/java/com/android254/presentation/feed/view/FeedScreen.kt @@ -60,9 +60,13 @@ fun FeedScreen( val scope = rememberCoroutineScope() feedViewModel.fetchFeed() val feedUIState = feedViewModel.viewState - if (bottomSheetState.isVisible) ModalBottomSheet(sheetState = bottomSheetState, - onDismissRequest = { scope.launch { bottomSheetState.hide() } }) { - FeedShareSection() + if (bottomSheetState.isVisible) { + ModalBottomSheet( + sheetState = bottomSheetState, + onDismissRequest = { scope.launch { bottomSheetState.hide() } } + ) { + FeedShareSection() + } } Scaffold(topBar = { DroidconAppBarWithFeedbackButton( @@ -107,7 +111,8 @@ fun FeedScreen( ) { items(feedUIState.feeds) { feedPresentationModel -> FeedComponent( - modifier = Modifier.fillMaxWidth(), feedPresentationModel + modifier = Modifier.fillMaxWidth(), + feedPresentationModel ) { scope.launch { bottomSheetState.show() diff --git a/presentation/src/main/java/com/android254/presentation/feedback/view/FeedBackScreen.kt b/presentation/src/main/java/com/android254/presentation/feedback/view/FeedBackScreen.kt index 1e1b5dc4..8c6402f0 100644 --- a/presentation/src/main/java/com/android254/presentation/feedback/view/FeedBackScreen.kt +++ b/presentation/src/main/java/com/android254/presentation/feedback/view/FeedBackScreen.kt @@ -171,7 +171,8 @@ fun FeedBackScreen( contentDescription = stringResource(id = R.string.sign_in_label) ) Text( - text = stringResource(R.string.Bad)) + text = stringResource(R.string.Bad) + ) } } Spacer(modifier = Modifier.width(20.dp)) diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index 81872397..400f0977 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -79,6 +79,7 @@ Bad Okay Great + Feed Image