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:
parent
d27e0b49ad
commit
13736ac909
7 changed files with 123 additions and 114 deletions
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
)
|
||||
},
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue