diff --git a/core/network/src/commonMain/kotlin/com/stslex/core/network/clients/profile/client/MockProfileClientImpl.kt b/core/network/src/commonMain/kotlin/com/stslex/core/network/clients/profile/client/MockProfileClientImpl.kt index 3721ea71..fdf11de7 100644 --- a/core/network/src/commonMain/kotlin/com/stslex/core/network/clients/profile/client/MockProfileClientImpl.kt +++ b/core/network/src/commonMain/kotlin/com/stslex/core/network/clients/profile/client/MockProfileClientImpl.kt @@ -74,6 +74,7 @@ class MockProfileClientImpl : ProfileClient { override suspend fun getFollowers( uuid: String, + query: String, page: Int, pageSize: Int ): UserFollowerResponse { @@ -92,6 +93,7 @@ class MockProfileClientImpl : ProfileClient { override suspend fun getFollowing( uuid: String, + query: String, page: Int, pageSize: Int ): UserFollowerResponse { diff --git a/core/network/src/commonMain/kotlin/com/stslex/core/network/clients/profile/client/ProfileClient.kt b/core/network/src/commonMain/kotlin/com/stslex/core/network/clients/profile/client/ProfileClient.kt index d22e5412..e7700f84 100644 --- a/core/network/src/commonMain/kotlin/com/stslex/core/network/clients/profile/client/ProfileClient.kt +++ b/core/network/src/commonMain/kotlin/com/stslex/core/network/clients/profile/client/ProfileClient.kt @@ -27,12 +27,14 @@ interface ProfileClient { suspend fun getFollowers( uuid: String, + query: String, page: Int, pageSize: Int ): UserFollowerResponse suspend fun getFollowing( uuid: String, + query: String, page: Int, pageSize: Int ): UserFollowerResponse diff --git a/core/network/src/commonMain/kotlin/com/stslex/core/network/clients/profile/client/ProfileClientImpl.kt b/core/network/src/commonMain/kotlin/com/stslex/core/network/clients/profile/client/ProfileClientImpl.kt index 599e1179..9f185ff7 100644 --- a/core/network/src/commonMain/kotlin/com/stslex/core/network/clients/profile/client/ProfileClientImpl.kt +++ b/core/network/src/commonMain/kotlin/com/stslex/core/network/clients/profile/client/ProfileClientImpl.kt @@ -58,11 +58,13 @@ class ProfileClientImpl( override suspend fun getFollowers( uuid: String, + query: String, page: Int, pageSize: Int ): UserFollowerResponse = client.request { get("$HOST/followers") { parameter("uuid", uuid) + parameter("query", query) parameter("page", page) parameter("page_size", pageSize) }.body() @@ -70,11 +72,13 @@ class ProfileClientImpl( override suspend fun getFollowing( uuid: String, + query: String, page: Int, pageSize: Int ): UserFollowerResponse = client.request { get("$HOST/following") { parameter("uuid", uuid) + parameter("query", query) parameter("page", page) parameter("page_size", pageSize) }.body() diff --git a/core/network/src/commonMain/kotlin/com/stslex/core/network/di/NetworkModule.kt b/core/network/src/commonMain/kotlin/com/stslex/core/network/di/NetworkModule.kt index fc102b7c..4ef7af46 100644 --- a/core/network/src/commonMain/kotlin/com/stslex/core/network/di/NetworkModule.kt +++ b/core/network/src/commonMain/kotlin/com/stslex/core/network/di/NetworkModule.kt @@ -16,6 +16,8 @@ import com.stslex.core.network.clients.film.client.FilmClient import com.stslex.core.network.clients.film.client.MockFilmClientImpl import com.stslex.core.network.clients.profile.client.ProfileClient import com.stslex.core.network.clients.profile.client.ProfileClientImpl +import com.stslex.core.network.utils.PagingWorker +import com.stslex.core.network.utils.PagingWorkerImpl import com.stslex.core.network.utils.token.AuthController import com.stslex.core.network.utils.token.AuthControllerImpl import org.koin.dsl.module @@ -72,4 +74,8 @@ val coreNetworkModule = module { userStore = get(), ) } + + factory { + PagingWorkerImpl() + } } \ No newline at end of file diff --git a/core/network/src/commonMain/kotlin/com/stslex/core/network/utils/PagingWorker.kt b/core/network/src/commonMain/kotlin/com/stslex/core/network/utils/PagingWorker.kt new file mode 100644 index 00000000..335f6353 --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/stslex/core/network/utils/PagingWorker.kt @@ -0,0 +1,9 @@ +package com.stslex.core.network.utils + +fun interface PagingWorker { + + suspend operator fun invoke( + request: suspend () -> Unit + ) +} + diff --git a/core/network/src/commonMain/kotlin/com/stslex/core/network/utils/PagingWorkerImpl.kt b/core/network/src/commonMain/kotlin/com/stslex/core/network/utils/PagingWorkerImpl.kt new file mode 100644 index 00000000..5e6d2c08 --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/stslex/core/network/utils/PagingWorkerImpl.kt @@ -0,0 +1,45 @@ +package com.stslex.core.network.utils + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlin.coroutines.coroutineContext + +class PagingWorkerImpl : PagingWorker { + private var job: Job? = null + private var nextPageJob: Job? = null + private var lastRequestTime = 0L + + override suspend fun invoke( + request: suspend () -> Unit + ) { + if (lastRequestTime + REQUEST_DELAY > currentTimeMs) { + nextPageJob = startRequest( + request = request, + start = CoroutineStart.LAZY + ) + } + startRequest(request = request) + } + + private suspend fun startRequest( + request: suspend () -> Unit, + start: CoroutineStart = CoroutineStart.DEFAULT, + ): Job = CoroutineScope(coroutineContext) + .launch( + start = start + ) { + job = nextPageJob + nextPageJob = null + request() + }.apply { + invokeOnCompletion { + nextPageJob?.start() + } + } + + companion object { + private const val REQUEST_DELAY = 500L + } +} \ No newline at end of file diff --git a/core/ui/src/commonMain/kotlin/com/stslex/core/ui/components/AppSnackbar.kt b/core/ui/src/commonMain/kotlin/com/stslex/core/ui/components/AppSnackbar.kt index 7a622a12..8dbd86cc 100644 --- a/core/ui/src/commonMain/kotlin/com/stslex/core/ui/components/AppSnackbar.kt +++ b/core/ui/src/commonMain/kotlin/com/stslex/core/ui/components/AppSnackbar.kt @@ -1,32 +1,158 @@ package com.stslex.core.ui.components import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraintsScope import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Snackbar import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Info import androidx.compose.material.rememberSwipeableState import androidx.compose.material.swipeable import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import com.stslex.core.ui.theme.AppDimension +import com.stslex.core.ui.theme.toPx +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlin.math.roundToInt + +@Composable +fun BoxWithConstraintsScope.AppSnackbarHost( + snackbarHostState: SnackbarHostState, + modifier: Modifier = Modifier, +) { + val width = maxWidth + AppSnackbarHost( + snackbarHostState = snackbarHostState, + width = width, + modifier = modifier + ) +} + +@Composable +fun BoxScope.AppSnackbarHost( + snackbarHostState: SnackbarHostState, + width: Dp, + modifier: Modifier = Modifier, +) { + SnackbarHost( + modifier = modifier + .align(Alignment.BottomCenter), + hostState = snackbarHostState + ) { snackbarData -> + val actionLabel = snackbarData.visuals.actionLabel ?: return@SnackbarHost + val action = SnackbarType.getByAction(actionLabel) ?: return@SnackbarHost + when (action) { + SnackbarType.ERROR -> ErrorSnackbar( + snackbarHostState = snackbarHostState, + message = snackbarData.visuals.message, + width = width, + ) + + SnackbarType.SUCCESS -> SuccessSnackbar( + snackbarHostState = snackbarHostState, + snackbarData.visuals.message, + width = width, + ) + + SnackbarType.INFO -> InfoSnackbar( + snackbarHostState = snackbarHostState, + snackbarData.visuals.message, + width = width, + ) + } + } +} + +@Composable +fun SuccessSnackbar( + snackbarHostState: SnackbarHostState, + message: String, + width: Dp, + modifier: Modifier = Modifier, +) { + AppSnackbar( + type = SnackbarType.SUCCESS, + message = message, + width = width, + snackbarHostState = snackbarHostState, + modifier = modifier + ) +} + +@Composable +fun InfoSnackbar( + snackbarHostState: SnackbarHostState, + message: String, + width: Dp, + modifier: Modifier = Modifier, +) { + AppSnackbar( + type = SnackbarType.INFO, + message = message, + width = width, + snackbarHostState = snackbarHostState, + modifier = modifier + ) +} + +@Composable +fun ErrorSnackbar( + snackbarHostState: SnackbarHostState, + message: String, + width: Dp, + modifier: Modifier = Modifier, +) { + AppSnackbar( + type = SnackbarType.ERROR, + message = message, + width = width, + snackbarHostState = snackbarHostState, + modifier = modifier + ) +} @OptIn(ExperimentalMaterialApi::class) @Composable fun AppSnackbar( type: SnackbarType, + message: String, + width: Dp, + snackbarHostState: SnackbarHostState, modifier: Modifier = Modifier, ) { - val swipeableState = rememberSwipeableState(SnackbarSwipeState.NONE) - var width by remember { mutableStateOf(0f) } + val swipeableState = rememberSwipeableState(SnackbarSwipeState.CENTER) + val widthPx = width.toPx + + LaunchedEffect(swipeableState) { + snapshotFlow { + swipeableState.offset.value + } + .filter { value -> + value == widthPx || value == -widthPx + } + .distinctUntilChanged() + .collect { + snackbarHostState.currentSnackbarData?.dismiss() + } + } Snackbar( modifier = modifier @@ -34,50 +160,48 @@ fun AppSnackbar( state = swipeableState, orientation = Orientation.Horizontal, anchors = mapOf( - 0f to SnackbarSwipeState.LEFT, - width * 0.5f to SnackbarSwipeState.RIGHT, - width to SnackbarSwipeState.RIGHT + -widthPx to SnackbarSwipeState.LEFT, + 0f to SnackbarSwipeState.CENTER, + widthPx to SnackbarSwipeState.RIGHT ), ) - .onGloballyPositioned { - width = it.size.width.toFloat() - }, + .offset { + IntOffset( + x = swipeableState.offset.value.roundToInt(), + y = 0 + ) + } + .padding(horizontal = AppDimension.Padding.medium), action = { - Text(text = "OK") + TextButton( + onClick = { + snackbarHostState.currentSnackbarData?.performAction() + } + ) { + // TODO repeat for errors??? + Text(text = "OK") + } } ) { - Row { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(AppDimension.Padding.small), + verticalAlignment = Alignment.CenterVertically + ) { Icon( - imageVector = Icons.Default.Info, - contentDescription = "info" + imageVector = type.imageVector, + contentDescription = type.contentDescription ) + Spacer(modifier = Modifier.padding(AppDimension.Padding.medium)) Text( - modifier = Modifier, - text = type.message, - color = MaterialTheme.colorScheme.onBackground + text = message, + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Start, + overflow = TextOverflow.Ellipsis, + maxLines = 2 ) } } } - -enum class SnackbarSwipeState { - LEFT, - RIGHT, - NONE -} - -sealed class SnackbarType( - val message: String -) { - data class Error( - private val mes: String - ) : SnackbarType(mes) - - data class Success( - private val mes: String - ) : SnackbarType(mes) - - data class Info( - private val mes: String - ) : SnackbarType(mes) -} \ No newline at end of file diff --git a/core/ui/src/commonMain/kotlin/com/stslex/core/ui/components/SnackbarSwipeState.kt b/core/ui/src/commonMain/kotlin/com/stslex/core/ui/components/SnackbarSwipeState.kt new file mode 100644 index 00000000..9b6afc73 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/com/stslex/core/ui/components/SnackbarSwipeState.kt @@ -0,0 +1,7 @@ +package com.stslex.core.ui.components + +internal enum class SnackbarSwipeState { + LEFT, + CENTER, + RIGHT, +} \ No newline at end of file diff --git a/core/ui/src/commonMain/kotlin/com/stslex/core/ui/components/SnackbarType.kt b/core/ui/src/commonMain/kotlin/com/stslex/core/ui/components/SnackbarType.kt new file mode 100644 index 00000000..4d069e8d --- /dev/null +++ b/core/ui/src/commonMain/kotlin/com/stslex/core/ui/components/SnackbarType.kt @@ -0,0 +1,38 @@ +package com.stslex.core.ui.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Warning +import androidx.compose.ui.graphics.vector.ImageVector + +enum class SnackbarType( + val label: String, + val imageVector: ImageVector, + val contentDescription: String +) { + ERROR( + label = "error", + imageVector = Icons.Default.Warning, + contentDescription = "Error" + ), + SUCCESS( + label = "success", + imageVector = Icons.Default.Done, + contentDescription = "Success" + ), + INFO( + label = "info", + imageVector = Icons.Default.Info, + contentDescription = "Info" + ); + + companion object { + + fun getByAction( + actionLabel: String? + ): SnackbarType? = entries.firstOrNull { type -> + type.label == actionLabel + } + } +} \ No newline at end of file diff --git a/core/ui/src/commonMain/kotlin/com/stslex/core/ui/mvi/BaseStore.kt b/core/ui/src/commonMain/kotlin/com/stslex/core/ui/mvi/BaseStore.kt index 28e6c7ce..3bd8ba2f 100644 --- a/core/ui/src/commonMain/kotlin/com/stslex/core/ui/mvi/BaseStore.kt +++ b/core/ui/src/commonMain/kotlin/com/stslex/core/ui/mvi/BaseStore.kt @@ -67,7 +67,7 @@ abstract class BaseStore( protected fun launch( onError: suspend (Throwable) -> Unit = {}, - onSuccess: (T) -> Unit = {}, + onSuccess: suspend CoroutineScope.(T) -> Unit = {}, action: suspend CoroutineScope.() -> T, ): Job = screenModelScope.launch( context = exceptionHandler(onError) + appDispatcher.default, diff --git a/core/ui/src/commonMain/kotlin/com/stslex/core/ui/mvi/Store.kt b/core/ui/src/commonMain/kotlin/com/stslex/core/ui/mvi/Store.kt index 391a2e92..90ba5a9a 100644 --- a/core/ui/src/commonMain/kotlin/com/stslex/core/ui/mvi/Store.kt +++ b/core/ui/src/commonMain/kotlin/com/stslex/core/ui/mvi/Store.kt @@ -1,10 +1,60 @@ package com.stslex.core.ui.mvi +import androidx.compose.material3.SnackbarDuration +import androidx.compose.runtime.Stable +import com.stslex.core.ui.components.SnackbarType + interface Store { interface State - interface Event + interface Event { + + @Stable + sealed class Snackbar( + open val message: String, + open val duration: SnackbarDuration, + open val withDismissAction: Boolean, + val action: String, + ) : Event { + + @Stable + data class Error( + override val message: String, + override val duration: SnackbarDuration = SnackbarDuration.Short, + override val withDismissAction: Boolean = false, + ) : Snackbar( + message = message, + action = SnackbarType.ERROR.label, + duration = duration, + withDismissAction = withDismissAction + ) + + @Stable + data class Success( + override val message: String, + override val duration: SnackbarDuration = SnackbarDuration.Short, + override val withDismissAction: Boolean = false, + ) : Snackbar( + message = message, + action = SnackbarType.SUCCESS.label, + duration = duration, + withDismissAction = withDismissAction + ) + + @Stable + data class Info( + override val message: String, + override val duration: SnackbarDuration = SnackbarDuration.Short, + override val withDismissAction: Boolean = false, + ) : Snackbar( + message = message, + action = SnackbarType.INFO.label, + duration = duration, + withDismissAction = withDismissAction + ) + } + } interface Navigation diff --git a/core/ui/src/commonMain/kotlin/com/stslex/core/ui/theme/AppDimension.kt b/core/ui/src/commonMain/kotlin/com/stslex/core/ui/theme/AppDimension.kt index e8629a45..196d4b69 100644 --- a/core/ui/src/commonMain/kotlin/com/stslex/core/ui/theme/AppDimension.kt +++ b/core/ui/src/commonMain/kotlin/com/stslex/core/ui/theme/AppDimension.kt @@ -23,4 +23,10 @@ object AppDimension { val small = 4.dp val medium = 8.dp } + + object Icon{ + val small = 16.dp + val medium = 24.dp + val large = 32.dp + } } \ No newline at end of file diff --git a/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/AuthScreen.kt b/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/AuthScreen.kt index 751d14aa..39b1fe20 100644 --- a/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/AuthScreen.kt +++ b/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/AuthScreen.kt @@ -6,11 +6,11 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.swipeable import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -21,15 +21,17 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.koin.getScreenModel -import com.stslex.feature.auth.ui.components.AuthTitle +import com.stslex.core.ui.components.AppSnackbarHost import com.stslex.core.ui.mvi.setupNavigator import com.stslex.core.ui.theme.AppDimension import com.stslex.core.ui.theme.toPx import com.stslex.feature.auth.ui.components.AuthFieldsColumn +import com.stslex.feature.auth.ui.components.AuthTitle import com.stslex.feature.auth.ui.model.screen.AuthScreenState import com.stslex.feature.auth.ui.model.screen.rememberAuthScreenState import com.stslex.feature.auth.ui.store.AuthStore import com.stslex.feature.auth.ui.store.AuthStoreComponent.AuthFieldsState +import com.stslex.feature.auth.ui.store.AuthStoreComponent.Event import com.stslex.feature.auth.ui.store.AuthStoreComponent.ScreenLoadingState object AuthScreen : Screen { @@ -44,7 +46,14 @@ object AuthScreen : Screen { LaunchedEffect(Unit) { store.event.collect { event -> - // TODO handle events + when (event) { + is Event.ShowSnackbar -> snackbarHostState.showSnackbar( + message = event.snackbar.message, + actionLabel = event.snackbar.action, + duration = event.snackbar.duration, + withDismissAction = event.snackbar.withDismissAction, + ) + } } } @@ -59,45 +68,46 @@ object AuthScreen : Screen { @OptIn(ExperimentalMaterialApi::class) @Composable -private fun AuthScreen(state: AuthScreenState) { +private fun AuthScreen( + state: AuthScreenState +) { BoxWithConstraints( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) ) { - val screenWidth = maxWidth.toPx + + val screenWidth = maxWidth + val screenWidthPx = screenWidth.toPx + Box( modifier = Modifier .fillMaxSize() + .systemBarsPadding() .background(MaterialTheme.colorScheme.background) .swipeable( state = state.swipeableState, orientation = Orientation.Horizontal, anchors = mapOf( - screenWidth to AuthFieldsState.AUTH, + screenWidthPx to AuthFieldsState.AUTH, 0f to AuthFieldsState.REGISTER ) ), contentAlignment = Alignment.Center, ) { AuthScreenContent(state) - SnackbarHost( - modifier = Modifier.align(Alignment.BottomCenter), - hostState = state.snackbarHostState - ) { snackbarData -> - // TODO -// when (SnackbarActionType.getByAction(snackbarData.visuals.actionLabel)) { -// SnackbarActionType.ERROR -> ErrorSnackbar(snackbarData) -// SnackbarActionType.SUCCESS -> SuccessSnackbar(snackbarData) -// SnackbarActionType.NONE -> return@SnackbarHost -// } - } + AppSnackbarHost( + snackbarHostState = state.snackbarHostState, + width = screenWidth + ) } + if (state.screenLoadingState == ScreenLoadingState.Loading) { Box( modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.onBackground.copy(alpha = 0.3f)), + .background(MaterialTheme.colorScheme.onBackground.copy(alpha = 0.3f)) + .systemBarsPadding(), contentAlignment = Alignment.Center ) { CircularProgressIndicator() diff --git a/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/model/SnackbarActionType.kt b/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/model/SnackbarActionType.kt index 527620bc..d6ed3e9f 100644 --- a/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/model/SnackbarActionType.kt +++ b/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/model/SnackbarActionType.kt @@ -1,4 +1,4 @@ -package com.stslex.aproselection.feature.auth.ui.model +package com.stslex.feature.auth.ui.model enum class SnackbarActionType(val action: String) { ERROR("error"), diff --git a/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/model/screen/AuthScreenState.kt b/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/model/screen/AuthScreenState.kt index 974b35cc..8a3ee9d9 100644 --- a/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/model/screen/AuthScreenState.kt +++ b/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/model/screen/AuthScreenState.kt @@ -102,8 +102,8 @@ fun rememberAuthScreenState( ) ) - LaunchedEffect(key1 = swipeableState.currentValue) { - processAction(Action.OnAuthFieldChange(swipeableState.currentValue)) + LaunchedEffect(key1 = swipeableState.targetValue) { + processAction(Action.OnAuthFieldChange(swipeableState.targetValue)) haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) } diff --git a/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/store/AuthStore.kt b/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/store/AuthStore.kt index e3363997..0de5f559 100644 --- a/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/store/AuthStore.kt +++ b/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/store/AuthStore.kt @@ -2,6 +2,7 @@ package com.stslex.feature.auth.ui.store import com.stslex.core.core.AppDispatcher import com.stslex.core.ui.mvi.BaseStore +import com.stslex.core.ui.mvi.Store.Event.Snackbar import com.stslex.feature.auth.domain.AuthInteractor import com.stslex.feature.auth.navigation.AuthRouter import com.stslex.feature.auth.ui.store.AuthStoreComponent.Action @@ -10,6 +11,7 @@ import com.stslex.feature.auth.ui.store.AuthStoreComponent.Event import com.stslex.feature.auth.ui.store.AuthStoreComponent.Navigation import com.stslex.feature.auth.ui.store.AuthStoreComponent.ScreenLoadingState import com.stslex.feature.auth.ui.store.AuthStoreComponent.State +import kotlinx.coroutines.delay class AuthStore( private val interactor: AuthInteractor, @@ -84,11 +86,32 @@ class AuthStore( setLoadingState(ScreenLoadingState.Loading) launch( - onError = { + action = { + interactor.register( + login = state.login, + username = state.username, + password = state.password + ) + }, + onError = { error -> + sendEvent( + Event.ShowSnackbar( + snackbar = Snackbar.Error( + message = error.message ?: "Unknown error" + ) + ) + ) setLoadingState(ScreenLoadingState.Content) - // TODO sendEvent(Event.ShowSnackbar.ERROR) }, onSuccess = { + sendEvent( + Event.ShowSnackbar( + snackbar = Snackbar.Success( + message = "Success register" + ) + ) + ) + delay(1000L) updateState { currentState -> currentState.copy( screenLoadingState = ScreenLoadingState.Content, @@ -96,26 +119,38 @@ class AuthStore( ) } navigate(Navigation.HomeFeature) - // TODO sendEvent(Event.ShowSnackbar.SuccessRegister) - }) { - interactor - .register( - login = state.login, - username = state.username, - password = state.password - ) - } + }) } private fun auth() { val state = state.value setLoadingState(ScreenLoadingState.Loading) launch( - onError = { + action = { + interactor.auth( + login = state.login, + password = state.password + ) + }, + onError = { error -> + sendEvent( + Event.ShowSnackbar( + snackbar = Snackbar.Error( + message = error.message ?: "Unknown error" + ) + ) + ) setLoadingState(ScreenLoadingState.Content) - // TODO sendEvent(Event.ShowSnackbar.ERROR) }, onSuccess = { + sendEvent( + Event.ShowSnackbar( + snackbar = Snackbar.Success( + message = "Success auth" + ) + ) + ) + delay(1000L) updateState { currentState -> currentState.copy( screenLoadingState = ScreenLoadingState.Content, @@ -123,14 +158,7 @@ class AuthStore( ) } navigate(Navigation.HomeFeature) - // TODO sendEvent(Event.ShowSnackbar.SuccessRegister) - }) { - interactor - .auth( - login = state.login, - password = state.password - ) - } + }) } private fun setLoadingState(screenLoadingState: ScreenLoadingState) { diff --git a/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/store/AuthStoreComponent.kt b/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/store/AuthStoreComponent.kt index 2a428759..fa62eefc 100644 --- a/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/store/AuthStoreComponent.kt +++ b/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/store/AuthStoreComponent.kt @@ -1,6 +1,7 @@ package com.stslex.feature.auth.ui.store import com.stslex.core.ui.mvi.Store +import com.stslex.core.ui.mvi.Store.Event.Snackbar interface AuthStoreComponent : Store { @@ -24,7 +25,12 @@ interface AuthStoreComponent : Store { } } - sealed interface Event : Store.Event + sealed interface Event : Store.Event { + + data class ShowSnackbar( + val snackbar: Snackbar + ) : Event + } sealed interface Action : Store.Action { data class OnSubmitClicked( diff --git a/feature/favourite/src/commonMain/kotlin/com/stslex/feature/favourite/di/FeatureFavouriteModule.kt b/feature/favourite/src/commonMain/kotlin/com/stslex/feature/favourite/di/FeatureFavouriteModule.kt index b5c846d2..531e1b55 100644 --- a/feature/favourite/src/commonMain/kotlin/com/stslex/feature/favourite/di/FeatureFavouriteModule.kt +++ b/feature/favourite/src/commonMain/kotlin/com/stslex/feature/favourite/di/FeatureFavouriteModule.kt @@ -12,7 +12,12 @@ import org.koin.dsl.module val featureFavouriteModule = module { factory { FavouriteRepositoryImpl(client = get()) } - factory { FavouriteInteractorImpl(repository = get()) } + factory { + FavouriteInteractorImpl( + repository = get(), + pagingWorker = get() + ) + } factory { FavouriteRouterImpl(navigator = get()) } factory { FavouriteStore( diff --git a/feature/favourite/src/commonMain/kotlin/com/stslex/feature/favourite/domain/interactor/FavouriteInteractorImpl.kt b/feature/favourite/src/commonMain/kotlin/com/stslex/feature/favourite/domain/interactor/FavouriteInteractorImpl.kt index 8b271e63..b12a0099 100644 --- a/feature/favourite/src/commonMain/kotlin/com/stslex/feature/favourite/domain/interactor/FavouriteInteractorImpl.kt +++ b/feature/favourite/src/commonMain/kotlin/com/stslex/feature/favourite/domain/interactor/FavouriteInteractorImpl.kt @@ -1,65 +1,29 @@ package com.stslex.feature.favourite.domain.interactor -import com.stslex.core.network.utils.currentTimeMs +import com.stslex.core.network.utils.PagingWorker import com.stslex.feature.favourite.data.repository.FavouriteRepository import com.stslex.feature.favourite.domain.model.FavouriteDomainModel import com.stslex.feature.favourite.domain.model.toData import com.stslex.feature.favourite.domain.model.toDomain -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.launch -import kotlin.coroutines.coroutineContext class FavouriteInteractorImpl( + private val pagingWorker: PagingWorker, private val repository: FavouriteRepository ) : FavouriteInteractor { private val _favourites = MutableSharedFlow>() override val favourites: SharedFlow> = _favourites.asSharedFlow() - private var favouritesJob: Job? = null - private var favouritesNextPageJob: Job? = null - private var lastRequestTime = 0L - override suspend fun getFavourites( uuid: String, query: String, page: Int, pageSize: Int ) { - if (lastRequestTime + REQUEST_DELAY > currentTimeMs) { - favouritesNextPageJob = getFavouritesJob( - uuid = uuid, - query = query, - page = page, - pageSize = pageSize, - start = CoroutineStart.LAZY - ) - } - favouritesJob = getFavouritesJob( - uuid = uuid, - query = query, - page = page, - pageSize = pageSize - ) - } - - private suspend fun getFavouritesJob( - uuid: String, - query: String, - page: Int, - pageSize: Int, - start: CoroutineStart = CoroutineStart.DEFAULT - ): Job = CoroutineScope(coroutineContext) - .launch( - start = start - ) { - favouritesJob = favouritesNextPageJob - favouritesNextPageJob = null + pagingWorker { val items = repository .getFavourites( uuid = uuid, @@ -76,11 +40,8 @@ class FavouriteInteractorImpl( .orEmpty() .plus(items) _favourites.emit(screenItems) - }.apply { - invokeOnCompletion { - favouritesNextPageJob?.start() - } } + } override suspend fun setFavourite(model: FavouriteDomainModel) { if (model.isFavourite) { @@ -89,8 +50,4 @@ class FavouriteInteractorImpl( repository.removeFavourite(model.uuid) } } - - companion object { - private const val REQUEST_DELAY = 500L - } } \ No newline at end of file diff --git a/feature/follower/src/commonMain/kotlin/com.stslex.feature.follower/data/repository/FollowerRepository.kt b/feature/follower/src/commonMain/kotlin/com.stslex.feature.follower/data/repository/FollowerRepository.kt index b2a48030..07f2d476 100644 --- a/feature/follower/src/commonMain/kotlin/com.stslex.feature.follower/data/repository/FollowerRepository.kt +++ b/feature/follower/src/commonMain/kotlin/com.stslex.feature.follower/data/repository/FollowerRepository.kt @@ -1,20 +1,21 @@ package com.stslex.feature.follower.data.repository import com.stslex.feature.follower.data.model.FollowerDataModel -import kotlinx.coroutines.flow.Flow interface FollowerRepository { - fun getFollowers( + suspend fun getFollowers( uuid: String, + query: String, page: Int, pageSize: Int - ): Flow> + ): List - fun getFollowing( + suspend fun getFollowing( uuid: String, + query: String, page: Int, pageSize: Int - ): Flow> + ): List } diff --git a/feature/follower/src/commonMain/kotlin/com.stslex.feature.follower/data/repository/FollowerRepositoryImpl.kt b/feature/follower/src/commonMain/kotlin/com.stslex.feature.follower/data/repository/FollowerRepositoryImpl.kt index 8d3e2ea5..fabe8978 100644 --- a/feature/follower/src/commonMain/kotlin/com.stslex.feature.follower/data/repository/FollowerRepositoryImpl.kt +++ b/feature/follower/src/commonMain/kotlin/com.stslex.feature.follower/data/repository/FollowerRepositoryImpl.kt @@ -3,36 +3,36 @@ package com.stslex.feature.follower.data.repository import com.stslex.core.network.clients.profile.client.ProfileClient import com.stslex.feature.follower.data.model.FollowerDataModel import com.stslex.feature.follower.data.model.toData -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow class FollowerRepositoryImpl( private val client: ProfileClient ) : FollowerRepository { - override fun getFollowers( + override suspend fun getFollowers( uuid: String, + query: String, page: Int, pageSize: Int - ): Flow> = flow { - val result = client.getFollowers( + ): List = client + .getFollowers( uuid = uuid, + query = query, page = page, pageSize = pageSize - ).toData() - emit(result) - } + ) + .toData() - override fun getFollowing( + override suspend fun getFollowing( uuid: String, + query: String, page: Int, pageSize: Int - ): Flow> = flow { - val result = client.getFollowing( + ): List = client + .getFollowing( uuid = uuid, + query = query, page = page, pageSize = pageSize - ).toData() - emit(result) - } + ) + .toData() } \ No newline at end of file diff --git a/feature/follower/src/commonMain/kotlin/com.stslex.feature.follower/di/FollowerModule.kt b/feature/follower/src/commonMain/kotlin/com.stslex.feature.follower/di/FollowerModule.kt index e7c60da0..715baaf9 100644 --- a/feature/follower/src/commonMain/kotlin/com.stslex.feature.follower/di/FollowerModule.kt +++ b/feature/follower/src/commonMain/kotlin/com.stslex.feature.follower/di/FollowerModule.kt @@ -19,7 +19,8 @@ val featureFollowerModule = module { factory { FollowerInteractorImpl( - repository = get() + repository = get(), + pagingWorker = get() ) } diff --git a/feature/follower/src/commonMain/kotlin/com.stslex.feature.follower/domain/interactor/FollowerInteractor.kt b/feature/follower/src/commonMain/kotlin/com.stslex.feature.follower/domain/interactor/FollowerInteractor.kt index 94416d74..aeffb277 100644 --- a/feature/follower/src/commonMain/kotlin/com.stslex.feature.follower/domain/interactor/FollowerInteractor.kt +++ b/feature/follower/src/commonMain/kotlin/com.stslex.feature.follower/domain/interactor/FollowerInteractor.kt @@ -1,20 +1,24 @@ package com.stslex.feature.follower.domain.interactor import com.stslex.feature.follower.ui.model.FollowerModel -import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow interface FollowerInteractor { - fun getFollowers( + val followItems: StateFlow> + + suspend fun getFollowers( uuid: String, + query: String, page: Int, pageSize: Int - ): Flow> + ) - fun getFollowing( + suspend fun getFollowing( uuid: String, + query: String, page: Int, pageSize: Int - ): Flow> + ) } diff --git a/feature/follower/src/commonMain/kotlin/com.stslex.feature.follower/domain/interactor/FollowerInteractorImpl.kt b/feature/follower/src/commonMain/kotlin/com.stslex.feature.follower/domain/interactor/FollowerInteractorImpl.kt index 9857f41f..25174e5f 100644 --- a/feature/follower/src/commonMain/kotlin/com.stslex.feature.follower/domain/interactor/FollowerInteractorImpl.kt +++ b/feature/follower/src/commonMain/kotlin/com.stslex.feature.follower/domain/interactor/FollowerInteractorImpl.kt @@ -1,35 +1,69 @@ package com.stslex.feature.follower.domain.interactor +import com.stslex.core.network.utils.PagingWorker +import com.stslex.feature.follower.data.model.FollowerDataModel import com.stslex.feature.follower.data.repository.FollowerRepository import com.stslex.feature.follower.domain.model.toUI import com.stslex.feature.follower.ui.model.FollowerModel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow class FollowerInteractorImpl( - private val repository: FollowerRepository + private val repository: FollowerRepository, + private val pagingWorker: PagingWorker ) : FollowerInteractor { - override fun getFollowers( + private val _followItems: MutableStateFlow> = MutableStateFlow(emptyList()) + override val followItems: StateFlow> = _followItems.asStateFlow() + + override suspend fun getFollowers( uuid: String, - page: Int, pageSize: Int - ): Flow> = repository - .getFollowers( - uuid = uuid, - page = page, - pageSize = pageSize - ) - .map { it.toUI() } + query: String, + page: Int, + pageSize: Int + ) { + pagingWorker { + repository + .getFollowers( + uuid = uuid, + query = query, + page = page, + pageSize = pageSize + ) + .let { items -> + onItemsLoaded(items) + } + } + } - override fun getFollowing( + override suspend fun getFollowing( uuid: String, + query: String, page: Int, pageSize: Int - ): Flow> = repository - .getFollowing( - uuid = uuid, - page = page, - pageSize = pageSize - ) - .map { it.toUI() } + ) { + pagingWorker { + repository + .getFollowing( + uuid = uuid, + query = query, + page = page, + pageSize = pageSize + ) + .let { items -> + onItemsLoaded(items) + } + } + } + + private suspend fun onItemsLoaded(data: List) { + val items = data.map { it.toUI() } + val screenItems = followItems + .replayCache + .lastOrNull() + .orEmpty() + .plus(items) + _followItems.emit(screenItems) + } } \ No newline at end of file diff --git a/feature/follower/src/commonMain/kotlin/com.stslex.feature.follower/ui/store/FollowerStore.kt b/feature/follower/src/commonMain/kotlin/com.stslex.feature.follower/ui/store/FollowerStore.kt index 74dd504f..7ccff878 100644 --- a/feature/follower/src/commonMain/kotlin/com.stslex.feature.follower/ui/store/FollowerStore.kt +++ b/feature/follower/src/commonMain/kotlin/com.stslex.feature.follower/ui/store/FollowerStore.kt @@ -12,6 +12,8 @@ import com.stslex.feature.follower.ui.store.FollowerStoreComponent.State import com.stslex.feature.follower.ui.store.FollowerStoreComponent.State.Companion.DEFAULT_PAGE import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map class FollowerStore( private val interactor: FollowerInteractor, @@ -44,7 +46,33 @@ class FollowerStore( screen = FollowerScreenState.Shimmer ) } - loadNextItems() + + state.map { it.query } + .distinctUntilChanged() + .launchFlow { query -> + updateState { state -> + state.copy( + page = DEFAULT_PAGE, + query = query, + ) + } + loadNextItems() + } + + interactor.followItems + .launchFlow { data -> + val screen = if (data.isEmpty()) { + FollowerScreenState.Empty + } else { + FollowerScreenState.Content.NotLoading + } + updateState { state -> + state.copy( + data = data.toImmutableList(), + screen = screen, + ) + } + } } private fun loadNextItems() { @@ -69,30 +97,28 @@ class FollowerStore( currentState.page.inc() } - loadingJob = when (val type = currentState.type) { - is FollowerScreenArgs.Follower -> interactor.getFollowers( - uuid = type.uuid, - page = page, - pageSize = PAGE_SIZE - ) + launch( + action = { + when (val type = currentState.type) { + is FollowerScreenArgs.Follower -> interactor.getFollowers( + uuid = type.uuid, + query = "", // todo add query + page = page, + pageSize = PAGE_SIZE + ) - is FollowerScreenArgs.Following -> interactor.getFollowing( - uuid = type.uuid, - page = page, - pageSize = PAGE_SIZE - ) - }.launchFlow( - each = { data -> - val screen = if (data.isEmpty()) { - FollowerScreenState.Empty - } else { - FollowerScreenState.Content.NotLoading + is FollowerScreenArgs.Following -> interactor.getFollowing( + uuid = type.uuid, + query = "", // todo add query + page = page, + pageSize = PAGE_SIZE + ) } + }, + onSuccess = { updateState { state -> state.copy( - page = page, - data = currentState.data.plus(data).toImmutableList(), - screen = screen + page = page ) } }, @@ -104,6 +130,7 @@ class FollowerStore( ) } } else { + updateState { it.copy(screen = FollowerScreenState.Content.NotLoading) } sendEvent(Event.ErrorSnackBar(error.message.orEmpty())) } } diff --git a/feature/follower/src/commonMain/kotlin/com.stslex.feature.follower/ui/store/FollowerStoreComponent.kt b/feature/follower/src/commonMain/kotlin/com.stslex.feature.follower/ui/store/FollowerStoreComponent.kt index 4f6ab829..000900d9 100644 --- a/feature/follower/src/commonMain/kotlin/com.stslex.feature.follower/ui/store/FollowerStoreComponent.kt +++ b/feature/follower/src/commonMain/kotlin/com.stslex.feature.follower/ui/store/FollowerStoreComponent.kt @@ -15,7 +15,8 @@ interface FollowerStoreComponent : Store { val page: Int, val type: FollowerScreenArgs, val data: ImmutableList, - val screen: FollowerScreenState + val screen: FollowerScreenState, + val query: String ) : Store.State { companion object { @@ -27,7 +28,8 @@ interface FollowerStoreComponent : Store { page = DEFAULT_PAGE, type = FollowerScreenArgs.Follower(""), data = emptyList().toImmutableList(), - screen = FollowerScreenState.Shimmer + screen = FollowerScreenState.Shimmer, + query = "" ) } } diff --git a/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/ProfileScreen.kt b/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/ProfileScreen.kt index 5801f635..2839ca0f 100644 --- a/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/ProfileScreen.kt +++ b/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/ProfileScreen.kt @@ -2,6 +2,7 @@ package com.stslex.feature.profile.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -9,6 +10,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -20,12 +22,14 @@ import androidx.compose.ui.Modifier import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.koin.getScreenModel import com.stslex.core.network.api.server.model.ErrorRefresh +import com.stslex.core.ui.components.AppSnackbarHost import com.stslex.core.ui.theme.AppDimension import com.stslex.feature.profile.navigation.ProfileScreenArguments import com.stslex.feature.profile.ui.components.ProfileScreenContent import com.stslex.feature.profile.ui.store.ProfileScreenState import com.stslex.feature.profile.ui.store.ProfileStore import com.stslex.feature.profile.ui.store.ProfileStoreComponent.Action +import com.stslex.feature.profile.ui.store.ProfileStoreComponent.Event import com.stslex.feature.profile.ui.store.ProfileStoreComponent.State data class ProfileScreen( @@ -39,17 +43,37 @@ data class ProfileScreen( store.sendAction(Action.Init(args = args)) } val state by remember { store.state }.collectAsState() + + val snackbarHostState = remember { SnackbarHostState() } + LaunchedEffect(Unit) { + store.event.collect { event -> + when (event) { + is Event.ShowSnackbar -> snackbarHostState.showSnackbar( + message = event.snackbar.message, + actionLabel = event.snackbar.action, + duration = event.snackbar.duration, + withDismissAction = event.snackbar.withDismissAction, + ) + } + } + } ProfileScreen( state = state, - onAction = store::sendAction + onAction = store::sendAction, + snackbarHostState = snackbarHostState ) } } @Composable -private fun ProfileScreen(state: State, onAction: (Action) -> Unit) { - Box( - modifier = Modifier +private fun ProfileScreen( + state: State, + onAction: (Action) -> Unit, + snackbarHostState: SnackbarHostState, + modifier: Modifier = Modifier, +) { + BoxWithConstraints( + modifier = modifier .fillMaxSize() .systemBarsPadding(), ) { @@ -67,6 +91,7 @@ private fun ProfileScreen(state: State, onAction: (Action) -> Unit) { ProfileScreenState.Shimmer -> ProfileScreenShinner() } + AppSnackbarHost(snackbarHostState) } } diff --git a/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/components/ProfileAvatar.kt b/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/components/ProfileAvatar.kt new file mode 100644 index 00000000..eb3cfac9 --- /dev/null +++ b/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/components/ProfileAvatar.kt @@ -0,0 +1,45 @@ +package com.stslex.feature.profile.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import com.stslex.core.ui.base.image.NetworkImage +import com.stslex.core.ui.theme.AppDimension +import com.stslex.feature.profile.ui.model.ProfileAvatarModel + +@Composable +fun ProfileAvatar( + avatar: ProfileAvatarModel, + modifier: Modifier = Modifier +) { + val avatarModifier = modifier + .size(AppDimension.Icon.large) + .clip(RoundedCornerShape(AppDimension.Radius.medium)) + + when (avatar) { + is ProfileAvatarModel.Content -> { + NetworkImage( + url = avatar.url, + modifier = avatarModifier + ) + } + + is ProfileAvatarModel.Empty -> { + Box( + modifier = avatarModifier + .background(avatar.color), + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = avatar.symbol, + ) + } + } + } +} \ No newline at end of file diff --git a/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/components/ProfileScreenContent.kt b/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/components/ProfileScreenContent.kt index 49f18b04..264cd83f 100644 --- a/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/components/ProfileScreenContent.kt +++ b/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/components/ProfileScreenContent.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.stslex.core.ui.base.image.NetworkImage import com.stslex.core.ui.theme.AppDimension +import com.stslex.feature.profile.ui.model.ProfileAvatarModel import com.stslex.feature.profile.ui.store.ProfileScreenState import com.stslex.feature.profile.ui.store.ProfileStoreComponent @@ -28,12 +29,24 @@ internal fun ProfileScreenContent( modifier = Modifier.align(Alignment.Center), verticalArrangement = Arrangement.Center, ) { - NetworkImage( - url = state.data.avatarUrl, - modifier = Modifier - .padding(AppDimension.Padding.big) - .align(Alignment.CenterHorizontally) - ) + when (val avatar = state.data.avatar) { + is ProfileAvatarModel.Empty -> { + Text( + text = avatar.symbol, + color = avatar.color + ) + } + + is ProfileAvatarModel.Content -> { + NetworkImage( + url = avatar.url, + modifier = Modifier + .padding(AppDimension.Padding.big) + .align(Alignment.CenterHorizontally) + ) + } + } + TextButton( onClick = { diff --git a/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/model/ProfileAvatarModel.kt b/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/model/ProfileAvatarModel.kt new file mode 100644 index 00000000..ec2046fc --- /dev/null +++ b/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/model/ProfileAvatarModel.kt @@ -0,0 +1,19 @@ +package com.stslex.feature.profile.ui.model + +import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.Color + +@Stable +sealed class ProfileAvatarModel { + + @Stable + data class Empty( + val symbol: String, + val color: Color, + ) : ProfileAvatarModel() + + @Stable + data class Content( + val url: String + ) : ProfileAvatarModel() +} \ No newline at end of file diff --git a/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/model/ProfileModel.kt b/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/model/ProfileModel.kt index b35d5aba..44706717 100644 --- a/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/model/ProfileModel.kt +++ b/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/model/ProfileModel.kt @@ -6,7 +6,7 @@ import androidx.compose.runtime.Stable data class ProfileModel( val uuid: String, val username: String, - val avatarUrl: String, + val avatar: ProfileAvatarModel, val bio: String, val followers: Int, val following: Int, diff --git a/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/model/ProfileUiMapper.kt b/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/model/ProfileUiMapper.kt index 294cae13..11442cda 100644 --- a/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/model/ProfileUiMapper.kt +++ b/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/model/ProfileUiMapper.kt @@ -2,10 +2,12 @@ package com.stslex.feature.profile.ui.model import com.stslex.feature.profile.domain.model.ProfileDomainModel -fun ProfileDomainModel.toUi() = ProfileModel( +fun ProfileDomainModel.toUi( + avatarModel: ProfileAvatarModel +) = ProfileModel( uuid = uuid, username = username, - avatarUrl = avatarUrl, + avatar = avatarModel, bio = bio, followers = followers, following = following, diff --git a/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/store/ProfileStore.kt b/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/store/ProfileStore.kt index b3887688..9af64dc1 100644 --- a/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/store/ProfileStore.kt +++ b/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/store/ProfileStore.kt @@ -1,10 +1,13 @@ package com.stslex.feature.profile.ui.store +import androidx.compose.ui.graphics.Color import com.stslex.core.core.AppDispatcher import com.stslex.core.database.store.UserStore import com.stslex.core.ui.mvi.BaseStore +import com.stslex.core.ui.mvi.Store.Event.Snackbar import com.stslex.feature.profile.domain.interactor.ProfileInteractor import com.stslex.feature.profile.navigation.ProfileRouter +import com.stslex.feature.profile.ui.model.ProfileAvatarModel import com.stslex.feature.profile.ui.model.toUi import com.stslex.feature.profile.ui.store.ProfileStoreComponent.Action import com.stslex.feature.profile.ui.store.ProfileStoreComponent.Event @@ -45,17 +48,28 @@ class ProfileStore( interactor.getProfile(uuid) .launchFlow( - onError = { + onError = { error -> updateState { currentState -> currentState.copy( - screen = ProfileScreenState.Error(it) + screen = ProfileScreenState.Error(error) ) } } ) { profile -> + val avatar = if (profile.avatarUrl.isBlank()) { + ProfileAvatarModel.Empty( + color = Color.Gray, // TODO replace with random color + symbol = profile.username.firstOrNull()?.lowercase().orEmpty() + ) + } else { + ProfileAvatarModel.Content(profile.avatarUrl) + } + val profileUi = profile.toUi( + avatarModel = avatar + ) updateState { currentState -> currentState.copy( - screen = ProfileScreenState.Content.NotLoading(profile.toUi()) + screen = ProfileScreenState.Content.NotLoading(profileUi) ) } } @@ -102,7 +116,7 @@ class ProfileStore( navigate(Navigation.LogIn) }, onError = { error -> - sendEvent(Event.ErrorSnackBar(error.message ?: "error logout")) + sendEvent(Event.ShowSnackbar(Snackbar.Error(error.message ?: "error logout"))) } ) } diff --git a/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/store/ProfileStoreComponent.kt b/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/store/ProfileStoreComponent.kt index dafa2ed0..23b44110 100644 --- a/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/store/ProfileStoreComponent.kt +++ b/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/store/ProfileStoreComponent.kt @@ -2,6 +2,7 @@ package com.stslex.feature.profile.ui.store import androidx.compose.runtime.Stable import com.stslex.core.ui.mvi.Store +import com.stslex.core.ui.mvi.Store.Event.Snackbar import com.stslex.feature.profile.navigation.ProfileScreenArguments interface ProfileStoreComponent : Store { @@ -46,7 +47,7 @@ interface ProfileStoreComponent : Store { sealed interface Event : Store.Event { @Stable - data class ErrorSnackBar(val message: String) : Event + data class ShowSnackbar(val snackbar: Snackbar) : Event } sealed interface Navigation : Store.Navigation { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9c89079b..dbb3a257 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,14 +4,14 @@ android-compileSdk = "34" android-targetSdk = "34" logback = "1.4.11" -compose = "1.5.4" +compose = "1.6.2" compose-plugin = "1.5.11" compose-compiler = "1.5.4" -agp = "8.1.4" -androidx-activityCompose = "1.8.1" +agp = "8.2.2" +androidx-activityCompose = "1.8.2" androidx-core-ktx = "1.12.0" androidx-appcompat = "1.6.1" -androidx-material = "1.10.0" +androidx-material = "1.11.0" androidx-constraintlayout = "2.1.4" androidx-test-junit = "1.1.5" androidx-espresso-core = "3.5.1" @@ -30,8 +30,8 @@ kamel = "0.9.0" coil = "2.5.0" buildConfig = "4.2.0" -lifecycleRuntimeKtx = "2.6.2" -composeBom = "2023.08.00" +lifecycleRuntimeKtx = "2.7.0" +composeBom = "2024.02.01" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }