1
0
Fork 0
mirror of https://github.com/anyproto/anytype-kotlin.git synced 2025-06-08 05:47:05 +09:00

DROID-2307 Gallery experience | Gallery import notification + fixes (#1045)

This commit is contained in:
Konstantin Ivanov 2024-04-02 12:43:19 +02:00 committed by GitHub
parent d27e0b49ad
commit 13736ac909
Signed by: github
GPG key ID: B5690EEEBB952194
7 changed files with 123 additions and 114 deletions

View file

@ -19,6 +19,7 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.fragment.findNavController
import com.anytypeio.anytype.R
import com.anytypeio.anytype.core_models.NO_VALUE
import com.anytypeio.anytype.core_ui.common.ComposeDialogView
@ -48,7 +49,7 @@ class GalleryInstallationFragment : BaseBottomSheetComposeFragment() {
@Inject
lateinit var factory: GalleryInstallationViewModelFactory
private val vm by viewModels<GalleryInstallationViewModel> { factory }
private lateinit var navController: NavHostController
private lateinit var currentNavController: NavHostController
@OptIn(ExperimentalMaterialNavigationApi::class, ExperimentalMaterial3Api::class)
override fun onCreateView(
@ -61,10 +62,10 @@ class GalleryInstallationFragment : BaseBottomSheetComposeFragment() {
setContent {
MaterialTheme {
val bottomSheetNavigator = rememberBottomSheetNavigator()
navController = rememberNavController(bottomSheetNavigator)
currentNavController = rememberNavController(bottomSheetNavigator)
val errorText = remember { mutableStateOf(NO_VALUE) }
val isErrorDialogVisible = remember { mutableStateOf(false) }
SetupNavigation(bottomSheetNavigator, navController)
SetupNavigation(bottomSheetNavigator, currentNavController)
LaunchedEffect(key1 = Unit) {
vm.errorState.collect { error ->
if (!error.isNullOrBlank()) {
@ -77,14 +78,8 @@ class GalleryInstallationFragment : BaseBottomSheetComposeFragment() {
BaseAlertDialog(
dialogText = errorText.value,
buttonText = stringResource(id = R.string.alert_qr_camera_ok),
onButtonClick = {
isErrorDialogVisible.value = false
errorText.value = NO_VALUE
},
onDismissRequest = {
isErrorDialogVisible.value = false
errorText.value = NO_VALUE
}
onButtonClick = vm::onDismiss,
onDismissRequest = vm::onDismiss
)
}
}
@ -97,14 +92,16 @@ class GalleryInstallationFragment : BaseBottomSheetComposeFragment() {
jobs += subscribe(vm.command) { command ->
Timber.d("GalleryInstallationFragment command: $command")
when (command) {
GalleryInstallationNavigation.Main -> navController.navigate(
GalleryInstallationNavigation.Main -> currentNavController.navigate(
GalleryInstallationNavigation.Main.route
)
GalleryInstallationNavigation.Spaces -> navController.navigate(
GalleryInstallationNavigation.Spaces -> currentNavController.navigate(
GalleryInstallationNavigation.Spaces.route
)
GalleryInstallationNavigation.Dismiss -> navController.popBackStack()
GalleryInstallationNavigation.Exit -> dismiss()
GalleryInstallationNavigation.CloseSpaces -> currentNavController.popBackStack()
GalleryInstallationNavigation.Dismiss -> {
findNavController().popBackStack()
}
else -> {}
}
}
@ -153,7 +150,7 @@ class GalleryInstallationFragment : BaseBottomSheetComposeFragment() {
state = vm.spacesViewState.collectAsStateWithLifecycle().value,
onNewSpaceClick = vm::onNewSpaceClick,
onSpaceClick = vm::onSpaceClick,
onDismiss = vm::onDismiss
onDismiss = vm::onCloseSpaces
)
}

View file

@ -18,9 +18,8 @@ data class GalleryInstallationSpacesState(
sealed class GalleryInstallationNavigation(val route: String) {
object Main : GalleryInstallationNavigation("main")
object Spaces : GalleryInstallationNavigation("spaces")
object Success : GalleryInstallationNavigation("success")
object CloseSpaces : GalleryInstallationNavigation("closeSpaces")
object Dismiss : GalleryInstallationNavigation("")
object Exit : GalleryInstallationNavigation("exit")
}
data class GallerySpaceView(

View file

@ -43,7 +43,7 @@ class GalleryInstallationViewModel(
val mainState = MutableStateFlow<GalleryInstallationState>(GalleryInstallationState.Loading)
val spacesViewState =
MutableStateFlow(GalleryInstallationSpacesState(emptyList(), false))
val command = MutableStateFlow<GalleryInstallationNavigation?>(null)
val command = MutableSharedFlow<GalleryInstallationNavigation>(replay = 0)
val errorState = MutableSharedFlow<String?>(replay = 0)
private val MAX_SPACES = 10
@ -86,7 +86,7 @@ class GalleryInstallationViewModel(
},
isNewButtonVisible = filteredSpaces.size < MAX_SPACES
)
command.value = GalleryInstallationNavigation.Spaces
command.emit(GalleryInstallationNavigation.Spaces)
},
onFailure = { error ->
Timber.e(error, "GetSpaceViews failed")
@ -97,18 +97,18 @@ class GalleryInstallationViewModel(
}
fun onNewSpaceClick() {
val state = (mainState.value as? GalleryInstallationState.Success) ?: return
subscribeToEventProcessChannel()
command.value = GalleryInstallationNavigation.Dismiss
val manifestInfo = state.info
mainState.value = state.copy(isLoading = true)
val params = CreateSpace.Params(
details = mapOf(
Relations.NAME to manifestInfo.title,
Relations.ICON_OPTION to spaceGradientProvider.randomId().toDouble()
)
)
Timber.d("onNewSpaceClick")
viewModelScope.launch {
command.emit(GalleryInstallationNavigation.CloseSpaces)
val state = (mainState.value as? GalleryInstallationState.Success) ?: return@launch
val manifestInfo = state.info
mainState.value = state.copy(isLoading = true)
val params = CreateSpace.Params(
details = mapOf(
Relations.NAME to manifestInfo.title,
Relations.ICON_OPTION to spaceGradientProvider.randomId().toDouble()
)
)
createSpace.async(params).fold(
onSuccess = { space ->
Timber.d("CreateSpace success, space: $space")
@ -116,7 +116,6 @@ class GalleryInstallationViewModel(
spaceId = SpaceId(space),
isNewSpace = true,
manifestInfo = manifestInfo,
state = state
)
},
onFailure = { error ->
@ -129,30 +128,39 @@ class GalleryInstallationViewModel(
}
fun onSpaceClick(space: GallerySpaceView) {
val state = (mainState.value as? GalleryInstallationState.Success) ?: return
subscribeToEventProcessChannel()
Timber.d("onSpaceClick, space: $space")
command.value = GalleryInstallationNavigation.Dismiss
mainState.value = state.copy(isLoading = true)
val spaceId = space.obj.targetSpaceId
if (spaceId == null) {
Timber.e("onSpaceClick, spaceId is null")
return
viewModelScope.launch {
command.emit(GalleryInstallationNavigation.CloseSpaces)
val state = (mainState.value as? GalleryInstallationState.Success) ?: return@launch
mainState.value = state.copy(isLoading = true)
val spaceId = space.obj.targetSpaceId
if (spaceId == null) {
Timber.e("onSpaceClick, spaceId is null")
return@launch
}
proceedWithInstallation(
spaceId = SpaceId(spaceId),
isNewSpace = false,
manifestInfo = state.info,
)
}
proceedWithInstallation(
spaceId = SpaceId(spaceId),
isNewSpace = false,
manifestInfo = state.info,
state = state
)
}
fun onDismiss() {
command.value = GalleryInstallationNavigation.Dismiss
Timber.d("onDismiss")
viewModelScope.launch {
command.emit(GalleryInstallationNavigation.Dismiss)
}
}
private fun proceedWithInstallation(
state: GalleryInstallationState.Success,
fun onCloseSpaces() {
Timber.d("onCloseSpaces")
viewModelScope.launch {
command.emit(GalleryInstallationNavigation.CloseSpaces)
}
}
private suspend fun proceedWithInstallation(
spaceId: SpaceId,
isNewSpace: Boolean,
manifestInfo: ManifestInfo
@ -163,18 +171,16 @@ class GalleryInstallationViewModel(
title = manifestInfo.title,
isNewSpace = isNewSpace
)
viewModelScope.launch {
importExperience.async(params).fold(
onSuccess = {
Timber.d("ObjectImportExperience success")
command.value = GalleryInstallationNavigation.Success
mainState.value = state.copy(isLoading = false)
importExperience.stream(params).collect { result ->
result.fold(
onLoading = {
//We immediately close the screen after sending the importExperience command,
// as either an error or success will be returned in the form of
// a Notification Event, which should be handled in the MainViewModel.
command.emit(GalleryInstallationNavigation.Dismiss)
},
onFailure = { error ->
Timber.e(error, "ObjectImportExperience failed")
mainState.value = state.copy(isLoading = false)
errorState.emit("Import experience error: ${error.message}")
}
onSuccess = { Timber.d("ObjectImportExperience success") },
onFailure = { error -> Timber.e(error, "ObjectImportExperience failed") }
)
}
}
@ -184,7 +190,7 @@ class GalleryInstallationViewModel(
eventProcessChannel.observe().collect { events ->
Timber.d("EventProcessChannel events: $events")
if (events.any { it is Process.Event.Done && it.process?.type == Process.Type.IMPORT }) {
command.value = GalleryInstallationNavigation.Exit
command.emit(GalleryInstallationNavigation.Dismiss)
}
}
}

View file

@ -28,6 +28,7 @@ import com.anytypeio.anytype.domain.wallpaper.ObserveWallpaper
import com.anytypeio.anytype.domain.wallpaper.RestoreWallpaper
import com.anytypeio.anytype.presentation.notifications.NotificationsProvider
import com.anytypeio.anytype.presentation.splash.SplashViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
@ -86,7 +87,7 @@ class MainViewModel(
}
}
viewModelScope.launch {
notificationsProvider.observe().collect { notifications ->
notificationsProvider.events.collect { notifications ->
notifications.forEach { event ->
handleNotification(event)
}
@ -97,6 +98,7 @@ class MainViewModel(
private suspend fun handleNotification(event: Notification.Event) {
val payload = event.notification?.payload
if (payload is NotificationPayload.GalleryImport) {
delay(DELAY_BEFORE_SHOWING_NOTIFICATION_SCREEN)
commands.emit(Command.Notifications)
}
}
@ -250,4 +252,8 @@ class MainViewModel(
}
object Notifications : Command()
}
companion object {
const val DELAY_BEFORE_SHOWING_NOTIFICATION_SCREEN = 200L
}
}

View file

@ -8,23 +8,35 @@ import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
interface NotificationsProvider {
fun observe(): Flow<List<Notification.Event>>
val events: StateFlow<List<Notification.Event>>
class Default @Inject constructor(
private val dispatchers: AppCoroutineDispatchers,
private val scope: CoroutineScope,
dispatchers: AppCoroutineDispatchers,
scope: CoroutineScope,
private val notificationsChannel: NotificationsChannel,
private val awaitAccountStartManager: AwaitAccountStartManager
) : NotificationsProvider {
private val _events = MutableStateFlow<List<Notification.Event>>(emptyList())
override val events: StateFlow<List<Notification.Event>> = _events
init {
scope.launch(dispatchers.io) {
observe().collect { events ->
_events.value = events
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
override fun observe(): Flow<List<Notification.Event>> {
private fun observe(): Flow<List<Notification.Event>> {
return awaitAccountStartManager.isStarted().flatMapLatest { isStarted ->
if (isStarted) notificationsChannel.observe() else emptyFlow()
}

View file

@ -28,10 +28,10 @@ class NotificationsViewModel(
init {
viewModelScope.launch {
notificationsProvider.observe().collect { notifications ->
notifications.forEach { event ->
handleNotification(event)
}
val notification = notificationsProvider.events.value
Timber.d("Received notifications in NotificationsViewModel: $notification")
if (notification.isNotEmpty()) {
handleNotification(notification.first())
}
}
}
@ -65,9 +65,10 @@ class NotificationsViewModel(
saveCurrentSpace.async(SaveCurrentSpace.Params(spaceId)).fold(
onFailure = {
Timber.e(it, "Error while saving current space in user settings")
command.value = Command.Dismiss
},
onSuccess = {
command.value = Command.NavigateToSpace(spaceId)
command.value = Command.Dismiss
}
)
},

View file

@ -9,22 +9,29 @@ import com.anytypeio.anytype.domain.account.AwaitAccountStartManager
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.domain.workspace.NotificationsChannel
import kotlin.time.Duration
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Assert.*
import kotlinx.coroutines.test.setMain
import org.junit.Assert.assertEquals
import org.junit.Test
import org.mockito.Mockito.mock
import org.mockito.kotlin.whenever
class NotificationsProviderTest {
private val dispatchers = mock<AppCoroutineDispatchers>()
private val scope = mock<CoroutineScope>()
val dispatcher = StandardTestDispatcher()
@OptIn(ExperimentalCoroutinesApi::class)
private val dispatchers = AppCoroutineDispatchers(
io = dispatcher,
main = dispatcher,
computation = dispatcher
).also { Dispatchers.setMain(dispatcher) }
private val scope = TestScope()
private val notificationsChannel = mock<NotificationsChannel>()
private val awaitAccountStartManager = AwaitAccountStartManager.Default
private val notificationsProvider = NotificationsProvider.Default(
@ -59,10 +66,10 @@ class NotificationsProviderTest {
awaitAccountStartManager.setIsStarted(true)
// Act
val result = notificationsProvider.observe().first()
// Assert
assertEquals(eventList, result)
notificationsProvider.events.test {
assertEquals(emptyList<Notification.Event>(), awaitItem())
assertEquals(eventList, awaitItem())
}
}
@Test
@ -73,44 +80,25 @@ class NotificationsProviderTest {
awaitAccountStartManager.setIsStarted(true) // Start and then stop
awaitAccountStartManager.setIsStarted(false)
notificationsProvider.observe().test(timeout = Duration.parse("2s")) { expectNoEvents() }
// Act & Assert
notificationsProvider.events.test(timeout = Duration.parse("2s")) {
assertEquals(emptyList<Notification.Event>(), awaitItem())
expectNoEvents()
}
}
@Test
fun `observe should emit the same event as sent by the channel when account is started`() =
runBlocking {
runTest {
// Arrange
val eventFlow = flowOf(listOf(testEvent))
whenever(notificationsChannel.observe()).thenReturn(eventFlow)
awaitAccountStartManager.setIsStarted(true)
// Act
val result = notificationsProvider.observe().first()
// Assert
assertEquals(listOf(testEvent), result)
}
@Test
fun `observe should not emit any events after the account is stopped`() = runTest {
// Arrange
whenever(notificationsChannel.observe()).thenReturn(flowOf(listOf(testEvent)))
awaitAccountStartManager.setIsStarted(true) // Start first
awaitAccountStartManager.setIsStarted(false) // Then stop
val collectedEvents = mutableListOf<List<Notification.Event>>()
// Act
val job = launch {
notificationsProvider.observe().collect { events ->
collectedEvents.add(events)
// Act & Assert
notificationsProvider.events.test(timeout = Duration.parse("2s")) {
assertEquals(emptyList<Notification.Event>(), awaitItem())
assertEquals(listOf(testEvent), awaitItem())
}
}
delay(100) // Short delay to allow any emissions to be collected
// Assert
assertTrue(collectedEvents.isEmpty()) // Verify no events were collected
job.cancel()
}
}