Skip to content

Commit

Permalink
refactoring note detail screen
Browse files Browse the repository at this point in the history
  • Loading branch information
babichev.a committed Oct 28, 2024
1 parent c63ab37 commit 92e987e
Show file tree
Hide file tree
Showing 7 changed files with 331 additions and 190 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
Expand All @@ -47,89 +47,71 @@ import org.jetbrains.compose.resources.stringResource

@Composable
fun NoteDetail(noteViewModel: NoteViewModel) {
val noteResultState: State<NoteResult> = noteViewModel.stateFlow.collectAsState()
val titleState: MutableState<String> = remember { mutableStateOf("") }
val textState: MutableState<String> = remember { mutableStateOf("") }
val result: NoteResult by noteViewModel.stateFlow.collectAsState()
val titleState: MutableState<String> = remember(key1 = noteViewModel, key2 = result) {
mutableStateOf(result.note?.title ?: "")
}
val textState: MutableState<String> = remember(key1 = noteViewModel, key2 = result) {
mutableStateOf(result.note?.text ?: "")
}
val snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(
key1 = noteViewModel,
key2 = noteResultState.value
) {
when (val noteResult: NoteResult = noteResultState.value) {
is NoteResult.Loading,
is NoteResult.Created -> Unit
is NoteResult.Loaded -> {
titleState.value = noteResult.result.title
textState.value = noteResult.result.text
}
is NoteResult.Saved -> {
titleState.value = noteResult.title
val noteSaved = getString(Res.string.note_saved) + ": " + noteResult.title
snackbarHostState.showSnackbar(noteSaved)
}
is NoteResult.TitleUpdated -> {
titleState.value = noteResult.title
}
is NoteResult.Empty -> snackbarHostState.showSnackbar(
message = getString(Res.string.note_empty)
)
is NoteResult.Deleted -> {
snackbarHostState.showSnackbar(message = getString(Res.string.note_deleted))
LaunchedEffect(key1 = noteViewModel, key2 = result, key3 = result.snackBarMessageType) {
result.snackBarMessageType?.let { snackBarMessageType: NoteResult.SnackBarMessageType ->
val msg: String = when (snackBarMessageType) {
NoteResult.SnackBarMessageType.SAVED -> getString(Res.string.note_saved) + ": " + titleState.value
NoteResult.SnackBarMessageType.EMPTY -> getString(Res.string.note_empty)
NoteResult.SnackBarMessageType.DELETED -> getString(Res.string.note_deleted)
}
snackbarHostState.showSnackbar(message = msg)
result.disposeOneTimeEvents()
}
}
NoteDetailBody(
snackbarHostState = snackbarHostState,
result = result,
titleState = titleState,
textState = textState,
onBackClick = { noteViewModel.checkSaveChange(titleState.value, textState.value) },
onSaveClick = noteViewModel::saveNote,
onEditClick = noteViewModel::editTitle,
onDeleteClick = noteViewModel::subscribeToDeleteNote,
showLoading = noteResultState.value == NoteResult.Loading,
snackbarHostState = snackbarHostState,
)
BackHandler { noteViewModel.checkSaveChange(titleState.value, textState.value) }
BackHandler { result.checkSaveChange(titleState.value, textState.value) }
}

@Composable
fun NoteDetailBody(
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
result: NoteResult = NoteResult(),
titleState: MutableState<String> = mutableStateOf("Title"),
textState: MutableState<String> = mutableStateOf("Text"),
onBackClick: () -> Unit = {},
onSaveClick: (title: String?, text: String) -> Unit = { _, _ -> },
onEditClick: () -> Unit = {},
onDeleteClick: () -> Unit = {},
showLoading: Boolean = true,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
) = Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text(text = titleState.value, maxLines = 1) },
navigationIcon = {
IconButton(onClick = onBackClick) {
IconButton(onClick = {
result.checkSaveChange(titleState.value, textState.value)
}) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = Icons.AutoMirrored.Filled.ArrowBack.name
)
}
},
actions = {
IconButton(onClick = { onSaveClick(titleState.value, textState.value) }) {
IconButton(onClick = { result.onSaveClick(titleState.value, textState.value) }) {
Icon(
Icons.Default.Save,
imageVector = Icons.Default.Save,
contentDescription = stringResource(Res.string.action_save_note)
)
}
IconButton(onClick = onEditClick) {
IconButton(onClick = result.onEditClick) {
Icon(
Icons.Default.Title,
imageVector = Icons.Default.Title,
contentDescription = stringResource(Res.string.action_edit_title)
)
}
IconButton(onClick = onDeleteClick) {
IconButton(onClick = result.onDeleteClick) {
Icon(
Icons.Default.Delete,
imageVector = Icons.Default.Delete,
contentDescription = stringResource(Res.string.action_delete_note)
)
}
Expand All @@ -140,7 +122,7 @@ fun NoteDetailBody(
modifier = Modifier.padding(paddingValues),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (showLoading) LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
if (result.loading) LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
TextField(
value = textState.value,
onValueChange = { textState.value = it },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import com.softartdev.notedelight.shared.usecase.note.SaveNoteUseCase
import com.softartdev.notedelight.shared.usecase.note.UpdateTitleUseCase
import io.github.aakira.napier.Napier
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.LocalDateTime
import org.junit.After
Expand All @@ -26,8 +27,12 @@ import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mockito.Mockito
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoMoreInteractions
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue

class NoteViewModelTest {

Expand Down Expand Up @@ -67,9 +72,12 @@ class NoteViewModelTest {
fun `init with noteId 0 creates new note`() = runTest {
val viewModel = createViewModel(noteId = 0)
viewModel.stateFlow.test {
assertEquals(NoteResult.Created(id), awaitItem())
val actualResult: NoteResult = awaitItem()

assertFalse(actualResult.loading)
assertEquals(note, actualResult.note)
verify(mockCreateNoteUseCase).invoke()
verifyNoMoreInteractions(mockNoteDAO)
verify(mockNoteDAO).load(id)

cancelAndIgnoreRemainingEvents()
}
Expand All @@ -79,42 +87,32 @@ class NoteViewModelTest {
fun `init with existing noteId loads note`() = runTest {
val viewModel = createViewModel(noteId = id)
viewModel.stateFlow.test {
assertEquals(NoteResult.Loaded(note), awaitItem())
val actualResult: NoteResult = awaitItem()

assertFalse(actualResult.loading)
assertEquals(note, actualResult.note)
verify(mockNoteDAO).load(id)
verifyNoMoreInteractions(mockCreateNoteUseCase)

cancelAndIgnoreRemainingEvents()
}
}

@Test
fun createNote() = runTest {
val viewModel = createViewModel(noteId = 0)
viewModel.stateFlow.test {
assertEquals(NoteResult.Created(id), awaitItem())

cancelAndIgnoreRemainingEvents()
}
}

@Test
fun loadNote() = runTest {
val viewModel = createViewModel(noteId = id)
viewModel.stateFlow.test {
assertEquals(NoteResult.Loaded(note), awaitItem())

cancelAndIgnoreRemainingEvents()
}
}

@Test
fun saveNoteEmpty() = runTest {
val viewModel = createViewModel(noteId = id)
viewModel.stateFlow.test {
assertEquals(NoteResult.Loaded(note), awaitItem())
var actualResult: NoteResult = awaitItem()
assertFalse(actualResult.loading)
assertEquals(note, actualResult.note)

viewModel.saveNote("", "")
assertEquals(NoteResult.Empty, awaitItem())
viewModel.stateFlow.value.onSaveClick("", "")
actualResult = awaitItem()
assertEquals(NoteResult.SnackBarMessageType.EMPTY, actualResult.snackBarMessageType)

viewModel.stateFlow.value.disposeOneTimeEvents()
actualResult = awaitItem()
assertNull(actualResult.snackBarMessageType)

cancelAndIgnoreRemainingEvents()
}
Expand All @@ -124,10 +122,20 @@ class NoteViewModelTest {
fun saveNote() = runTest {
val viewModel = createViewModel(noteId = id)
viewModel.stateFlow.test {
assertEquals(NoteResult.Loaded(note), awaitItem())
var actualResult: NoteResult = awaitItem()
assertFalse(actualResult.loading)
assertEquals(note, actualResult.note)

viewModel.stateFlow.value.onSaveClick(title, text)
verify(mockNoteDAO, times(2)).load(id)

viewModel.saveNote(title, text)
assertEquals(NoteResult.Saved(title), awaitItem())
actualResult = awaitItem()
assertEquals(note, actualResult.note)
assertTrue(actualResult.loading)

advanceUntilIdle()
actualResult = awaitItem()
assertFalse(actualResult.loading)

cancelAndIgnoreRemainingEvents()
}
Expand All @@ -137,14 +145,21 @@ class NoteViewModelTest {
fun editTitle() = runTest {
val viewModel = createViewModel(noteId = id)
viewModel.stateFlow.test {
assertEquals(NoteResult.Loaded(note), awaitItem())
var actualResult: NoteResult = awaitItem()
assertFalse(actualResult.loading)
assertEquals(note, actualResult.note)

viewModel.editTitle()
viewModel.stateFlow.value.onEditClick()
verify(mockRouter).navigate(route = AppNavGraph.EditTitleDialog(noteId = id))

UpdateTitleUseCase.titleChannel.send(title)
assertEquals(NoteResult.Loading, awaitItem())
assertEquals(NoteResult.TitleUpdated(title), awaitItem())
actualResult = awaitItem()
assertTrue(actualResult.loading)
assertEquals(title, actualResult.note?.title)

advanceUntilIdle()
actualResult = awaitItem()
assertFalse(actualResult.loading)

cancelAndIgnoreRemainingEvents()
}
Expand All @@ -154,10 +169,23 @@ class NoteViewModelTest {
fun deleteNote() = runTest {
val viewModel = createViewModel(noteId = id)
viewModel.stateFlow.test {
assertEquals(NoteResult.Loaded(note), awaitItem())
var actualResult: NoteResult = awaitItem()
assertFalse(actualResult.loading)
assertEquals(note, actualResult.note)

viewModel.deleteNote()
assertEquals(NoteResult.Deleted, awaitItem())
viewModel.stateFlow.value.onDeleteClick()
verify(mockRouter).navigate(route = AppNavGraph.DeleteNoteDialog)

DeleteNoteUseCase.deleteChannel.send(true)
verify(mockDeleteNoteUseCase).invoke(id)

actualResult = awaitItem()
assertTrue(actualResult.loading)
assertNull(actualResult.snackBarMessageType)

actualResult = awaitItem()
verify(mockRouter).popBackStack(route = AppNavGraph.Main, inclusive = false, saveState = false)
assertFalse(actualResult.loading)

cancelAndIgnoreRemainingEvents()
}
Expand All @@ -169,11 +197,11 @@ class NoteViewModelTest {
viewModel.stateFlow.test {
Mockito.`when`(mockNoteDAO.load(id)).thenReturn(note.copy(text = "new text"))

viewModel.checkSaveChange(title, text)
viewModel.stateFlow.value.checkSaveChange(title, text)
verify(mockRouter).navigate(route = AppNavGraph.SaveChangesDialog)

SaveNoteUseCase.dialogChannel.send(true)
verify(mockRouter).popBackStack()
verify(mockRouter).navigateClearingBackStack(route = AppNavGraph.Main)

verifyNoMoreInteractions(mockRouter)
cancelAndIgnoreRemainingEvents()
Expand All @@ -184,7 +212,7 @@ class NoteViewModelTest {
fun checkSaveChangeNavBack() = runTest {
val viewModel = createViewModel(noteId = id)
viewModel.stateFlow.test {
viewModel.checkSaveChange(title, text)
viewModel.stateFlow.value.checkSaveChange(title, text)
verify(mockRouter).popBackStack()

cancelAndIgnoreRemainingEvents()
Expand All @@ -195,11 +223,14 @@ class NoteViewModelTest {
fun checkSaveChangeDeleted() = runTest {
val viewModel = createViewModel(noteId = id)
viewModel.stateFlow.test {
assertEquals(NoteResult.Loaded(note), awaitItem())
val actualResult: NoteResult = awaitItem()
assertFalse(actualResult.loading)
assertEquals(note, actualResult.note)

Mockito.`when`(mockNoteDAO.load(id)).thenReturn(note.copy(text = "", title = ""))
viewModel.checkSaveChange("", "")
assertEquals(NoteResult.Deleted, awaitItem())
viewModel.stateFlow.value.checkSaveChange("", "")
verify(mockDeleteNoteUseCase).invoke(id)
verify(mockRouter).popBackStack(route = AppNavGraph.Main, inclusive = false, saveState = false)

cancelAndIgnoreRemainingEvents()
}
Expand All @@ -209,7 +240,18 @@ class NoteViewModelTest {
fun saveNoteAndNavBack() = runTest {
val viewModel = createViewModel(noteId = id)
viewModel.stateFlow.test {
viewModel.saveNoteAndNavBack(title, text)
var actualResult: NoteResult = awaitItem()
assertFalse(actualResult.loading)
assertEquals(note, actualResult.note)

viewModel.stateFlow.value.onSaveClick(title, text)
actualResult = awaitItem()
assertTrue(actualResult.loading)

actualResult = awaitItem()
assertFalse(actualResult.loading)

viewModel.stateFlow.value.checkSaveChange(title, text)
verify(mockRouter).popBackStack()

cancelAndIgnoreRemainingEvents()
Expand All @@ -220,7 +262,7 @@ class NoteViewModelTest {
fun doNotSaveAndNavBack() = runTest {
val viewModel = createViewModel(noteId = id)
viewModel.stateFlow.test {
viewModel.doNotSaveAndNavBack()
viewModel.stateFlow.value.checkSaveChange(title, text)
verify(mockRouter).popBackStack()

cancelAndIgnoreRemainingEvents()
Expand All @@ -231,11 +273,14 @@ class NoteViewModelTest {
fun doNotSaveAndNavBackDeleted() = runTest {
val viewModel = createViewModel(noteId = id)
viewModel.stateFlow.test {
assertEquals(NoteResult.Loaded(note), awaitItem())
val actualResult: NoteResult = awaitItem()
assertFalse(actualResult.loading)
assertEquals(note, actualResult.note)

Mockito.`when`(mockNoteDAO.load(id)).thenReturn(note.copy(text = "", title = ""))
viewModel.doNotSaveAndNavBack()
assertEquals(NoteResult.Deleted, awaitItem())
viewModel.stateFlow.value.checkSaveChange("", "")
verify(mockDeleteNoteUseCase).invoke(id)
verify(mockRouter).popBackStack(route = AppNavGraph.Main, inclusive = false, saveState = false)

cancelAndIgnoreRemainingEvents()
}
Expand Down
Loading

0 comments on commit 92e987e

Please sign in to comment.