Change type of ViewFileState.lines from ImmutableList<String> to AsyncData<List<String>> to properly handle loading and error states.

This commit is contained in:
Benoit Marty 2024-01-25 10:19:56 +01:00
parent 6c6c215cd6
commit 647c431f59
8 changed files with 102 additions and 60 deletions

View file

@ -47,4 +47,5 @@ dependencies {
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testImplementation(projects.libraries.matrix.test)
} }

View file

@ -24,21 +24,16 @@ import java.io.File
import javax.inject.Inject import javax.inject.Inject
interface FileContentReader { interface FileContentReader {
suspend fun getLines(path: String): List<String> suspend fun getLines(path: String): Result<List<String>>
} }
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultFileContentReader @Inject constructor( class DefaultFileContentReader @Inject constructor(
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
) : FileContentReader { ) : FileContentReader {
override suspend fun getLines(path: String): List<String> = withContext(dispatchers.io) { override suspend fun getLines(path: String): Result<List<String>> = withContext(dispatchers.io) {
try { runCatching {
File(path).readLines() File(path).readLines()
} catch (exception: Exception) {
buildList {
add("Error reading file $path")
add(exception.toString())
}
} }
} }
} }

View file

@ -26,8 +26,8 @@ import androidx.compose.runtime.setValue
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -57,13 +57,16 @@ class ViewFilePresenter @AssistedInject constructor(
} }
} }
var lines by remember { mutableStateOf(emptyList<String>()) } var lines: AsyncData<List<String>> by remember { mutableStateOf(AsyncData.Loading()) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
lines = fileContentReader.getLines(path) lines = fileContentReader.getLines(path).fold(
onSuccess = { AsyncData.Success(it) },
onFailure = { AsyncData.Failure(it) }
)
} }
return ViewFileState( return ViewFileState(
name = name, name = name,
lines = lines.toImmutableList(), lines = lines,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }

View file

@ -16,10 +16,10 @@
package io.element.android.features.viewfolder.impl.file package io.element.android.features.viewfolder.impl.file
import kotlinx.collections.immutable.ImmutableList import io.element.android.libraries.architecture.AsyncData
data class ViewFileState( data class ViewFileState(
val name: String, val name: String,
val lines: ImmutableList<String>, val lines: AsyncData<List<String>>,
val eventSink: (ViewFileEvents) -> Unit, val eventSink: (ViewFileEvents) -> Unit,
) )

View file

@ -17,24 +17,29 @@
package io.element.android.features.viewfolder.impl.file package io.element.android.features.viewfolder.impl.file
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import kotlinx.collections.immutable.toImmutableList import io.element.android.libraries.architecture.AsyncData
open class ViewFileStateProvider : PreviewParameterProvider<ViewFileState> { open class ViewFileStateProvider : PreviewParameterProvider<ViewFileState> {
override val values: Sequence<ViewFileState> override val values: Sequence<ViewFileState>
get() = sequenceOf( get() = sequenceOf(
aViewFileState(), aViewFileState(),
aViewFileState(lines = AsyncData.Loading()),
aViewFileState(lines = AsyncData.Failure(Exception("A failure"))),
aViewFileState(lines = AsyncData.Success(emptyList())),
aViewFileState( aViewFileState(
lines = listOf( lines = AsyncData.Success(
"Line 1", listOf(
"Line 2", "Line 1",
"Line 3 lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor" + "Line 2",
" incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,", "Line 3 lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor" +
"01-23 13:14:50.740 25818 25818 V verbose", " incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,",
"01-23 13:14:50.740 25818 25818 D debug", "01-23 13:14:50.740 25818 25818 V verbose",
"01-23 13:14:50.740 25818 25818 I info", "01-23 13:14:50.740 25818 25818 D debug",
"01-23 13:14:50.740 25818 25818 W warning", "01-23 13:14:50.740 25818 25818 I info",
"01-23 13:14:50.740 25818 25818 E error", "01-23 13:14:50.740 25818 25818 W warning",
"01-23 13:14:50.740 25818 25818 A assertion", "01-23 13:14:50.740 25818 25818 E error",
"01-23 13:14:50.740 25818 25818 A assertion",
)
) )
) )
) )
@ -42,9 +47,9 @@ open class ViewFileStateProvider : PreviewParameterProvider<ViewFileState> {
fun aViewFileState( fun aViewFileState(
name: String = "aName", name: String = "aName",
lines: List<String> = emptyList(), lines: AsyncData<List<String>> = AsyncData.Uninitialized,
) = ViewFileState( ) = ViewFileState(
name = name, name = name,
lines = lines.toImmutableList(), lines = lines,
eventSink = {}, eventSink = {},
) )

View file

@ -41,6 +41,9 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.androidutils.system.copyToClipboard import io.element.android.libraries.androidutils.system.copyToClipboard
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.async.AsyncFailure
import io.element.android.libraries.designsystem.components.async.AsyncLoading
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.icons.CompoundDrawables import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@ -103,35 +106,51 @@ fun ViewFileView(
.padding(padding) .padding(padding)
.consumeWindowInsets(padding) .consumeWindowInsets(padding)
) { ) {
LazyColumn( when (state.lines) {
modifier = Modifier.weight(1f) AsyncData.Uninitialized,
) { is AsyncData.Loading -> AsyncLoading()
if (state.lines.isEmpty()) { is AsyncData.Success -> FileContent(
item { modifier = modifier.weight(1f),
Spacer(Modifier.size(80.dp)) lines = state.lines.data,
Text( )
text = "Empty file", is AsyncData.Failure -> AsyncFailure(throwable = state.lines.error, onRetry = null)
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.fillMaxWidth()
)
}
} else {
itemsIndexed(
items = state.lines,
) { index, line ->
LineRow(
lineNumber = index + 1,
line = line,
)
}
}
} }
} }
} }
) )
} }
@Composable
private fun FileContent(
lines: List<String>,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier
) {
if (lines.isEmpty()) {
item {
Spacer(Modifier.size(80.dp))
Text(
text = "Empty file",
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.fillMaxWidth()
)
}
} else {
itemsIndexed(
items = lines,
) { index, line ->
LineRow(
lineNumber = index + 1,
line = line,
)
}
}
}
}
@Composable @Composable
private fun LineRow( private fun LineRow(
lineNumber: Int, lineNumber: Int,

View file

@ -19,11 +19,11 @@ package io.element.android.features.viewfolder.test.file
import io.element.android.features.viewfolder.impl.file.FileContentReader import io.element.android.features.viewfolder.impl.file.FileContentReader
class FakeFileContentReader : FileContentReader { class FakeFileContentReader : FileContentReader {
private var result: List<String> = emptyList() private var result: Result<List<String>> = Result.success(emptyList())
fun givenResult(result: List<String>) { fun givenResult(result: Result<List<String>>) {
this.result = result this.result = result
} }
override suspend fun getLines(path: String): List<String> = result override suspend fun getLines(path: String): Result<List<String>> = result
} }

View file

@ -25,6 +25,8 @@ import io.element.android.features.viewfolder.impl.file.FileSave
import io.element.android.features.viewfolder.impl.file.FileShare import io.element.android.features.viewfolder.impl.file.FileShare
import io.element.android.features.viewfolder.impl.file.ViewFileEvents import io.element.android.features.viewfolder.impl.file.ViewFileEvents
import io.element.android.features.viewfolder.impl.file.ViewFilePresenter import io.element.android.features.viewfolder.impl.file.ViewFilePresenter
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
@ -37,24 +39,26 @@ class ViewFilePresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val fileContentReader = FakeFileContentReader().apply { val fileContentReader = FakeFileContentReader().apply {
givenResult(listOf("aLine")) givenResult(Result.success(listOf("aLine")))
} }
val presenter = createPresenter(fileContentReader = fileContentReader) val presenter = createPresenter(fileContentReader = fileContentReader)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.name).isEqualTo("aName") assertThat(initialState.name).isEqualTo("aName")
assertThat(initialState.lines.size).isEqualTo(1) assertThat(initialState.lines).isInstanceOf(AsyncData.Loading::class.java)
assertThat(initialState.lines.first()).isEqualTo("aLine") val loadedState = awaitItem()
val lines = (loadedState.lines as AsyncData.Success).data
assertThat(lines.size).isEqualTo(1)
assertThat(lines.first()).isEqualTo("aLine")
} }
} }
@Test @Test
fun `present - share should not have any side effect`() = runTest { fun `present - share should not have any side effect`() = runTest {
val fileContentReader = FakeFileContentReader().apply { val fileContentReader = FakeFileContentReader().apply {
givenResult(listOf("aLine")) givenResult(Result.success(listOf("aLine")))
} }
val fileShare = FakeFileShare() val fileShare = FakeFileShare()
val fileSave = FakeFileSave() val fileSave = FakeFileSave()
@ -70,10 +74,25 @@ class ViewFilePresenterTest {
} }
} }
@Test
fun `present - with error loading file`() = runTest {
val fileContentReader = FakeFileContentReader().apply {
givenResult(Result.failure(AN_EXCEPTION))
}
val presenter = createPresenter(fileContentReader = fileContentReader)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val errorState = awaitItem()
assertThat(errorState.lines).isInstanceOf(AsyncData.Failure::class.java)
}
}
@Test @Test
fun `present - save should not have any side effect`() = runTest { fun `present - save should not have any side effect`() = runTest {
val fileContentReader = FakeFileContentReader().apply { val fileContentReader = FakeFileContentReader().apply {
givenResult(listOf("aLine")) givenResult(Result.success(listOf("aLine")))
} }
val fileShare = FakeFileShare() val fileShare = FakeFileShare()
val fileSave = FakeFileSave() val fileSave = FakeFileSave()