diff --git a/shared-compose-ui/src/commonMain/kotlin/com/softartdev/notedelight/App.kt b/shared-compose-ui/src/commonMain/kotlin/com/softartdev/notedelight/App.kt index c6c94d06..56972d81 100644 --- a/shared-compose-ui/src/commonMain/kotlin/com/softartdev/notedelight/App.kt +++ b/shared-compose-ui/src/commonMain/kotlin/com/softartdev/notedelight/App.kt @@ -52,10 +52,7 @@ fun App( SignInScreen(signInViewModel = getViewModel()) } composable(route = AppNavGraph.Main.name) { - MainScreen( - mainViewModel = getViewModel(), - navController = navController - ) + MainScreen(mainViewModel = getViewModel()) } composable( route = "${AppNavGraph.Details.name}/{noteId}", diff --git a/shared-compose-ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/MainScreen.kt b/shared-compose-ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/MainScreen.kt index f80770e3..75b69b35 100644 --- a/shared-compose-ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/MainScreen.kt +++ b/shared-compose-ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/MainScreen.kt @@ -26,10 +26,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription -import androidx.navigation.NavHostController -import androidx.navigation.compose.rememberNavController -import com.softartdev.notedelight.shared.navigation.AppNavGraph -import com.softartdev.notedelight.shared.db.Note import com.softartdev.notedelight.shared.db.TestSchema import com.softartdev.notedelight.shared.presentation.main.MainViewModel import com.softartdev.notedelight.shared.presentation.main.NoteListResult @@ -40,27 +36,15 @@ import notedelight.shared_compose_ui.generated.resources.settings import org.jetbrains.compose.resources.stringResource @Composable -fun MainScreen( - mainViewModel: MainViewModel, - navController: NavHostController = rememberNavController() -) { +fun MainScreen(mainViewModel: MainViewModel) { LaunchedEffect(mainViewModel) { mainViewModel.updateNotes() } - val noteListState: State = mainViewModel.resultStateFlow.collectAsState() + val noteListState: State = mainViewModel.stateFlow.collectAsState() MainScreen( noteListState = noteListState, - onItemClicked = { id: Long -> - navController.navigate(route = "${AppNavGraph.Details.name}/$id") - }, - onSettingsClick = { - navController.navigate(AppNavGraph.Settings.name) - }, - navSignIn = { - navController.navigate(AppNavGraph.SignIn.name) { - popUpTo(AppNavGraph.SignIn.name) { inclusive = true } - } - }, + onItemClicked = mainViewModel::onNoteClicked, + onSettingsClick = mainViewModel::onSettingsClicked, ) } @@ -69,7 +53,6 @@ fun MainScreen( noteListState: State, onItemClicked: (id: Long) -> Unit = {}, onSettingsClick: () -> Unit = {}, - navSignIn: () -> Unit = {} ) = Scaffold( topBar = { TopAppBar( @@ -87,10 +70,14 @@ fun MainScreen( when (val noteListResult = noteListState.value) { is NoteListResult.Loading -> Loader() is NoteListResult.Success -> { - val notes: List = noteListResult.result - if (notes.isNotEmpty()) NoteList(notes, onItemClicked) else Empty() + when { + noteListResult.result.isNotEmpty() -> NoteList( + noteList = noteListResult.result, + onItemClicked = onItemClicked, + ) + else -> Empty() + } } - is NoteListResult.NavSignIn -> navSignIn() is NoteListResult.Error -> Error(err = noteListResult.error ?: "Error") } } diff --git a/shared/src/androidUnitTest/kotlin/com/softartdev/notedelight/shared/presentation/main/MainViewModelTest.kt b/shared/src/androidUnitTest/kotlin/com/softartdev/notedelight/shared/presentation/main/MainViewModelTest.kt index 382564a1..33275286 100644 --- a/shared/src/androidUnitTest/kotlin/com/softartdev/notedelight/shared/presentation/main/MainViewModelTest.kt +++ b/shared/src/androidUnitTest/kotlin/com/softartdev/notedelight/shared/presentation/main/MainViewModelTest.kt @@ -6,6 +6,8 @@ import app.cash.turbine.test import com.softartdev.notedelight.shared.db.Note import com.softartdev.notedelight.shared.db.NoteDAO import com.softartdev.notedelight.shared.db.SafeRepo +import com.softartdev.notedelight.shared.navigation.AppNavGraph +import com.softartdev.notedelight.shared.navigation.Router import com.softartdev.notedelight.shared.presentation.MainDispatcherRule import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flow @@ -27,18 +29,19 @@ class MainViewModelTest { val mainDispatcherRule = MainDispatcherRule() private val mockSafeRepo = Mockito.mock(SafeRepo::class.java) + private val mockRouter = Mockito.mock(Router::class.java) private val mockNoteDAO = Mockito.mock(NoteDAO::class.java) private lateinit var mainViewModel: MainViewModel @Before fun setUp() { Mockito.`when`(mockSafeRepo.noteDAO).thenReturn(mockNoteDAO) - mainViewModel = MainViewModel(mockSafeRepo, mockNoteDAO) + mainViewModel = MainViewModel(mockSafeRepo, mockRouter) } @Test fun success() = runTest { - mainViewModel.resultStateFlow.test { + mainViewModel.stateFlow.test { assertEquals(NoteListResult.Loading, awaitItem()) val notes = emptyList() @@ -52,12 +55,12 @@ class MainViewModelTest { @Test fun navMain() = runTest { - mainViewModel.resultStateFlow.test { + mainViewModel.stateFlow.test { assertEquals(NoteListResult.Loading, awaitItem()) Mockito.`when`(mockNoteDAO.listFlow).thenReturn(flow { throw SQLiteException() }) mainViewModel.updateNotes() - assertEquals(NoteListResult.NavSignIn, awaitItem()) + Mockito.verify(mockRouter).navigateClearingBackStack(route = AppNavGraph.SignIn.name) cancelAndIgnoreRemainingEvents() } @@ -65,7 +68,7 @@ class MainViewModelTest { @Test fun error() = runTest { - mainViewModel.resultStateFlow.test { + mainViewModel.stateFlow.test { assertEquals(NoteListResult.Loading, awaitItem()) Mockito.`when`(mockNoteDAO.listFlow).thenReturn(flow { throw Throwable() }) diff --git a/shared/src/androidUnitTest/kotlin/com/softartdev/notedelight/shared/presentation/signin/SignInViewModelTest.kt b/shared/src/androidUnitTest/kotlin/com/softartdev/notedelight/shared/presentation/signin/SignInViewModelTest.kt index c5069d7f..80784b57 100644 --- a/shared/src/androidUnitTest/kotlin/com/softartdev/notedelight/shared/presentation/signin/SignInViewModelTest.kt +++ b/shared/src/androidUnitTest/kotlin/com/softartdev/notedelight/shared/presentation/signin/SignInViewModelTest.kt @@ -5,6 +5,8 @@ import app.cash.turbine.test import com.softartdev.notedelight.shared.presentation.MainDispatcherRule import com.softartdev.notedelight.shared.StubEditable import com.softartdev.notedelight.shared.anyObject +import com.softartdev.notedelight.shared.navigation.AppNavGraph +import com.softartdev.notedelight.shared.navigation.Router import com.softartdev.notedelight.shared.usecase.crypt.CheckPasswordUseCase import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -24,16 +26,18 @@ class SignInViewModelTest { val mainDispatcherRule = MainDispatcherRule() private val mockCheckPasswordUseCase = Mockito.mock(CheckPasswordUseCase::class.java) + private val mockRouter = Mockito.mock(Router::class.java) + private lateinit var signInViewModel: SignInViewModel @Before fun setUp() { - signInViewModel = SignInViewModel(mockCheckPasswordUseCase) + signInViewModel = SignInViewModel(mockCheckPasswordUseCase, mockRouter) } @Test fun showSignInForm() = runTest { - signInViewModel.resultStateFlow.test { + signInViewModel.stateFlow.test { assertEquals(SignInResult.ShowSignInForm, awaitItem()) cancelAndIgnoreRemainingEvents() } @@ -41,14 +45,13 @@ class SignInViewModelTest { @Test fun navMain() = runTest { - signInViewModel.resultStateFlow.test { + signInViewModel.stateFlow.test { assertEquals(SignInResult.ShowSignInForm, awaitItem()) val pass = StubEditable("pass") Mockito.`when`(mockCheckPasswordUseCase(pass)).thenReturn(true) signInViewModel.signIn(pass) - assertEquals(SignInResult.ShowProgress, awaitItem()) - assertEquals(SignInResult.NavMain, awaitItem()) + Mockito.verify(mockRouter).navigateClearingBackStack(route = AppNavGraph.Main.name) cancelAndIgnoreRemainingEvents() } @@ -56,11 +59,10 @@ class SignInViewModelTest { @Test fun showEmptyPassError() = runTest { - signInViewModel.resultStateFlow.test { + signInViewModel.stateFlow.test { assertEquals(SignInResult.ShowSignInForm, awaitItem()) signInViewModel.signIn(pass = StubEditable("")) - assertEquals(SignInResult.ShowProgress, awaitItem()) assertEquals(SignInResult.ShowEmptyPassError, awaitItem()) cancelAndIgnoreRemainingEvents() @@ -69,13 +71,12 @@ class SignInViewModelTest { @Test fun showIncorrectPassError() = runTest { - signInViewModel.resultStateFlow.test { + signInViewModel.stateFlow.test { assertEquals(SignInResult.ShowSignInForm, awaitItem()) val pass = StubEditable("pass") Mockito.`when`(mockCheckPasswordUseCase(pass)).thenReturn(false) signInViewModel.signIn(pass) - assertEquals(SignInResult.ShowProgress, awaitItem()) assertEquals(SignInResult.ShowIncorrectPassError, awaitItem()) cancelAndIgnoreRemainingEvents() @@ -84,15 +85,15 @@ class SignInViewModelTest { @Test fun showError() = runTest { - signInViewModel.resultStateFlow.test { + signInViewModel.stateFlow.test { assertEquals(SignInResult.ShowSignInForm, awaitItem()) val throwable = Throwable() Mockito.`when`(mockCheckPasswordUseCase(anyObject())).thenThrow(throwable) signInViewModel.signIn(StubEditable("pass")) - assertEquals(SignInResult.ShowProgress, awaitItem()) - assertEquals(SignInResult.ShowError(throwable), awaitItem()) - + Mockito.verify(mockRouter).navigate( + route = AppNavGraph.ErrorDialog.argRoute(message = throwable.message) + ) cancelAndIgnoreRemainingEvents() } } diff --git a/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/main/MainViewModel.kt b/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/main/MainViewModel.kt index 41341bdb..5608eb96 100644 --- a/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/main/MainViewModel.kt +++ b/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/main/MainViewModel.kt @@ -1,31 +1,55 @@ package com.softartdev.notedelight.shared.presentation.main -import com.softartdev.notedelight.shared.base.BaseStateViewModel -import com.softartdev.notedelight.shared.db.NoteDAO +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.softartdev.notedelight.shared.db.SafeRepo +import com.softartdev.notedelight.shared.navigation.AppNavGraph +import com.softartdev.notedelight.shared.navigation.Router +import io.github.aakira.napier.Napier +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch class MainViewModel( private val safeRepo: SafeRepo, - private val noteDAO: NoteDAO, -) : BaseStateViewModel() { - - override val loadingResult: NoteListResult = NoteListResult.Loading + private val router: Router +) : ViewModel() { + private val mutableStateFlow: MutableStateFlow = MutableStateFlow( + value = NoteListResult.Loading + ) + val stateFlow: StateFlow = mutableStateFlow init { safeRepo.relaunchListFlowCallback = this::updateNotes } - fun updateNotes() = launch( - useIdling = false, - flow = safeRepo.noteDAO.listFlow.map(NoteListResult::Success) - ) + fun updateNotes() = viewModelScope.launch(Dispatchers.Main) { + safeRepo.noteDAO.listFlow + .onStart { mutableStateFlow.value = NoteListResult.Loading } + .map(transform = NoteListResult::Success) + .onEach(action = mutableStateFlow::emit) + .flowOn(Dispatchers.IO) + .catch { throwable -> + Napier.e("❌", throwable) + val errorName: String = throwable::class.simpleName.orEmpty() + if (errorName.contains("SQLite")) { + router.navigateClearingBackStack(AppNavGraph.SignIn.name) + } else { + mutableStateFlow.value = NoteListResult.Error(throwable.message) + } + }.launchIn(this) + }.ensureActive() - override fun errorResult(throwable: Throwable): NoteListResult { - val errorName: String = throwable::class.simpleName.orEmpty() - return when { - errorName.contains("SQLite") -> NoteListResult.NavSignIn - else -> NoteListResult.Error(throwable.message) - } - } + fun onNoteClicked(id: Long) = router.navigate(route = "${AppNavGraph.Details.name}/$id") + + fun onSettingsClicked() = router.navigate(route = AppNavGraph.Settings.name) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/main/NoteListResult.kt b/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/main/NoteListResult.kt index a2b49f09..3821810d 100644 --- a/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/main/NoteListResult.kt +++ b/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/main/NoteListResult.kt @@ -2,9 +2,8 @@ package com.softartdev.notedelight.shared.presentation.main import com.softartdev.notedelight.shared.db.Note -sealed class NoteListResult{ - object Loading : NoteListResult() - data class Success(val result: List) : NoteListResult() - object NavSignIn : NoteListResult() - data class Error(val error: String? = null) : NoteListResult() -} \ No newline at end of file +sealed interface NoteListResult { + data object Loading : NoteListResult + data class Success(val result: List) : NoteListResult + data class Error(val error: String? = null) : NoteListResult +} diff --git a/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/signin/SignInViewModel.kt b/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/signin/SignInViewModel.kt index e3b20cad..df461cc6 100644 --- a/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/signin/SignInViewModel.kt +++ b/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/signin/SignInViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import com.softartdev.notedelight.shared.navigation.AppNavGraph import com.softartdev.notedelight.shared.navigation.Router import com.softartdev.notedelight.shared.usecase.crypt.CheckPasswordUseCase +import io.github.aakira.napier.Napier import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -30,6 +31,7 @@ class SignInViewModel( else -> SignInResult.ShowIncorrectPassError } } catch (error: Throwable) { + Napier.e("❌", error) router.navigate(route = AppNavGraph.ErrorDialog.argRoute(message = error.message)) mutableStateFlow.value = SignInResult.ShowSignInForm } diff --git a/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/splash/SplashViewModel.kt b/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/splash/SplashViewModel.kt index 19032d9d..1ea78a93 100644 --- a/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/splash/SplashViewModel.kt +++ b/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/splash/SplashViewModel.kt @@ -6,6 +6,7 @@ import com.softartdev.notedelight.shared.PlatformSQLiteState import com.softartdev.notedelight.shared.db.SafeRepo import com.softartdev.notedelight.shared.navigation.AppNavGraph import com.softartdev.notedelight.shared.navigation.Router +import io.github.aakira.napier.Napier import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -26,6 +27,7 @@ class SplashViewModel( } ) } catch (error: Throwable) { + Napier.e("❌", error) router.navigate(route = AppNavGraph.ErrorDialog.argRoute(message = error.message)) } mutableStateFlow.value = false