Skip to content

Commit 36aac1c

Browse files
committed
feat(ui):Add ErrorPanel composable to Comments Section, related UI models, and tests
1 parent 1d94fd1 commit 36aac1c

10 files changed

Lines changed: 435 additions & 17 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package org.schabi.newpipe.ui.components.video.comments
2+
3+
import android.net.http.NetworkException
4+
import androidx.paging.LoadState
5+
import androidx.test.ext.junit.rules.ActivityScenarioRule
6+
import androidx.test.ext.junit.runners.AndroidJUnit4
7+
import org.junit.Assert.assertEquals
8+
import org.junit.Assert.assertFalse
9+
import org.junit.Assert.assertTrue
10+
import org.junit.Rule
11+
import org.junit.Test
12+
import org.junit.runner.RunWith
13+
import org.schabi.newpipe.MainActivity
14+
import org.schabi.newpipe.R
15+
import org.schabi.newpipe.ui.UiModel.UnableToLoadCommentsUiModel
16+
import org.schabi.newpipe.viewmodels.util.Resource
17+
18+
@RunWith(AndroidJUnit4::class)
19+
class CommentSectionErrorTest {
20+
21+
@get:Rule
22+
val activityRule = ActivityScenarioRule(MainActivity::class.java)
23+
24+
/**
25+
* Test Resource.Error state - when initial comment info loading fails
26+
*/
27+
@Test
28+
fun testResourceErrorState_ShowsUnableToLoadCommentsUiModel() {
29+
30+
val networkException = object : NetworkException("Connection attempt timed out", null) {
31+
override fun getErrorCode(): Int = NetworkException.ERROR_CONNECTION_TIMED_OUT
32+
override fun isImmediatelyRetryable() = true
33+
}
34+
val errorResource = Resource.Error(networkException)
35+
assertEquals("Should contain the network exception", networkException, errorResource.throwable)
36+
37+
val errorModel = UnableToLoadCommentsUiModel(networkException)
38+
val spec = errorModel.spec
39+
40+
assertEquals("Should have correct message resource", R.string.error_unable_to_load_comments, spec.messageRes)
41+
assertTrue("Should show retry button", spec.showRetry)
42+
assertTrue("Should show report button", spec.showReport)
43+
assertFalse("Should NOT show open in browser button", spec.showOpenInBrowser)
44+
}
45+
46+
/**
47+
* Test LoadState.Error state - when paging data loading fails
48+
*/
49+
@Test
50+
fun testLoadStateErrorState_ShowsUnableToLoadCommentsUiModel() {
51+
val pagingException = RuntimeException("Paging data loading failed")
52+
val loadStateError = LoadState.Error(pagingException)
53+
54+
assertEquals("Should contain the paging exception", pagingException, loadStateError.error)
55+
56+
val errorModel = UnableToLoadCommentsUiModel(pagingException)
57+
val spec = errorModel.spec
58+
59+
assertEquals("Should have correct message resource", R.string.error_unable_to_load_comments, spec.messageRes)
60+
assertTrue("Should show retry button", spec.showRetry)
61+
assertTrue("Should show report button", spec.showReport)
62+
assertFalse("Should NOT show open in browser button", spec.showOpenInBrowser)
63+
}
64+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package org.schabi.newpipe.error
2+
3+
import org.schabi.newpipe.ui.UiModel.ErrorUiModel
4+
import org.schabi.newpipe.ui.UiModel.GenericErrorUiModel
5+
import org.schabi.newpipe.ui.UiModel.UnableToLoadCommentsUiModel
6+
7+
fun mapThrowableToErrorUiModel(throwable: Throwable, userAction: UserAction? = null): ErrorUiModel {
8+
if (userAction == UserAction.REQUESTED_COMMENTS) {
9+
10+
return UnableToLoadCommentsUiModel(rawError = throwable)
11+
}
12+
// Other ErrorInfo logic and throwable + user actions
13+
return GenericErrorUiModel(rawError = throwable)
14+
}

app/src/main/java/org/schabi/newpipe/paging/CommentsSource.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,22 @@ import org.schabi.newpipe.extractor.Page
99
import org.schabi.newpipe.extractor.comments.CommentsInfo
1010
import org.schabi.newpipe.extractor.comments.CommentsInfoItem
1111
import org.schabi.newpipe.ui.components.video.comment.CommentInfo
12+
import java.io.IOException
1213

1314
class CommentsSource(private val commentInfo: CommentInfo) : PagingSource<Page, CommentsInfoItem>() {
1415
private val service = NewPipe.getService(commentInfo.serviceId)
1516

1617
override suspend fun load(params: LoadParams<Page>): LoadResult<Page, CommentsInfoItem> {
1718
// params.key is null the first time the load() function is called, so we need to return the
1819
// first batch of already-loaded comments
20+
return LoadResult.Error(IOException("💥 forced test error"))
1921
if (params.key == null) {
2022
return LoadResult.Page(commentInfo.comments, null, commentInfo.nextPage)
2123
} else {
2224
val info = withContext(Dispatchers.IO) {
2325
CommentsInfo.getMoreItems(service, commentInfo.url, params.key)
2426
}
27+
2528
return LoadResult.Page(info.items, null, info.nextPage)
2629
}
2730
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package org.schabi.newpipe.ui.UiModel
2+
3+
import androidx.compose.runtime.Immutable
4+
import org.schabi.newpipe.R
5+
import org.schabi.newpipe.ui.components.common.ErrorPanelSpec
6+
7+
/**
8+
* Each concrete case from this hierarchy represents a different failure state that the UI can render with ErrorPanel
9+
*/
10+
@Immutable
11+
sealed interface ErrorUiModel {
12+
val spec: ErrorPanelSpec
13+
val rawError: Throwable? get() = null
14+
}
15+
16+
/**
17+
* Concrete cases - Comments unable to load, Comments disabled, No connectivity, DNS failure, timeout etc
18+
*/
19+
@Immutable
20+
data class UnableToLoadCommentsUiModel(override val rawError: Throwable?) : ErrorUiModel {
21+
override val spec: ErrorPanelSpec =
22+
ErrorPanelSpec(
23+
messageRes = R.string.error_unable_to_load_comments,
24+
showRetry = true,
25+
showReport = true,
26+
showOpenInBrowser = false
27+
)
28+
}
29+
30+
/**
31+
* A generic ErrorUiModel for unhandled cases
32+
*/
33+
@Immutable
34+
data class GenericErrorUiModel(override val rawError: Throwable?) : ErrorUiModel {
35+
override val spec: ErrorPanelSpec =
36+
ErrorPanelSpec(
37+
messageRes = R.string.general_error,
38+
showRetry = true,
39+
showReport = true,
40+
)
41+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package org.schabi.newpipe.ui.components.common
2+
3+
import androidx.annotation.StringRes
4+
import androidx.compose.foundation.layout.Column
5+
import androidx.compose.foundation.layout.Spacer
6+
import androidx.compose.foundation.layout.height
7+
import androidx.compose.foundation.layout.padding
8+
import androidx.compose.foundation.layout.wrapContentWidth
9+
import androidx.compose.material3.MaterialTheme
10+
import androidx.compose.material3.Text
11+
import androidx.compose.runtime.Composable
12+
import androidx.compose.ui.Alignment
13+
import androidx.compose.ui.Modifier
14+
import androidx.compose.ui.res.stringResource
15+
import androidx.compose.ui.text.font.FontWeight
16+
import androidx.compose.ui.text.style.TextAlign
17+
import androidx.compose.ui.tooling.preview.Preview
18+
import org.schabi.newpipe.R
19+
import org.schabi.newpipe.ui.theme.AppTheme
20+
import org.schabi.newpipe.ui.theme.SizeTokens.SpacingExtraLarge
21+
import org.schabi.newpipe.ui.theme.SizeTokens.SpacingLarge
22+
import org.schabi.newpipe.ui.theme.SizeTokens.SpacingMedium
23+
import org.schabi.newpipe.ui.theme.SizeTokens.SpacingSmall
24+
25+
@Composable
26+
fun ErrorPanel(
27+
spec: ErrorPanelSpec,
28+
onRetry: () -> Unit,
29+
onReport: () -> Unit,
30+
onOpenInBrowser: () -> Unit,
31+
modifier: Modifier = Modifier
32+
33+
) {
34+
Column(
35+
horizontalAlignment = Alignment.CenterHorizontally,
36+
modifier = Modifier
37+
.wrapContentWidth()
38+
.padding(horizontal = SpacingLarge, vertical = SpacingMedium)
39+
40+
) {
41+
42+
val message = stringResource(spec.messageRes)
43+
44+
Text(
45+
text = message,
46+
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
47+
textAlign = TextAlign.Center
48+
)
49+
50+
spec.serviceInfoRes?.let { infoRes ->
51+
Spacer(Modifier.height(SpacingSmall))
52+
val serviceInfo = stringResource(infoRes)
53+
Text(
54+
text = serviceInfo,
55+
style = MaterialTheme.typography.bodyMedium,
56+
textAlign = TextAlign.Center
57+
)
58+
}
59+
60+
spec.serviceExplanationRes?.let { explanationRes ->
61+
Spacer(Modifier.height(SpacingSmall))
62+
val serviceExplanation = stringResource(explanationRes)
63+
Text(
64+
text = serviceExplanation,
65+
style = MaterialTheme.typography.bodyMedium,
66+
textAlign = TextAlign.Center
67+
)
68+
}
69+
Spacer(Modifier.height(SpacingMedium))
70+
if (spec.showReport) {
71+
ServiceColoredButton(onClick = onReport) {
72+
Text(stringResource(R.string.error_snackbar_action).uppercase())
73+
}
74+
}
75+
if (spec.showRetry) {
76+
ServiceColoredButton(onClick = onRetry) {
77+
Text(stringResource(R.string.retry).uppercase())
78+
}
79+
}
80+
if (spec.showOpenInBrowser) {
81+
ServiceColoredButton(onClick = onOpenInBrowser) {
82+
Text(stringResource(R.string.open_in_browser).uppercase())
83+
}
84+
}
85+
Spacer(Modifier.height(SpacingExtraLarge))
86+
}
87+
}
88+
89+
data class ErrorPanelSpec(
90+
@StringRes val messageRes: Int,
91+
@StringRes val serviceInfoRes: Int? = null,
92+
val serviceExplanation: String? = null,
93+
@StringRes val serviceExplanationRes: Int? = null,
94+
val showRetry: Boolean = false,
95+
val showReport: Boolean = false,
96+
val showOpenInBrowser: Boolean = false
97+
)
98+
99+
@Preview(showBackground = true, widthDp = 360, heightDp = 640)
100+
101+
@Composable
102+
fun ErrorPanelPreview() {
103+
AppTheme {
104+
ErrorPanel(
105+
spec = ErrorPanelSpec(
106+
messageRes = android.R.string.httpErrorBadUrl,
107+
showRetry = true,
108+
showReport = false,
109+
showOpenInBrowser = false
110+
),
111+
onRetry = {},
112+
onReport = {},
113+
onOpenInBrowser = {},
114+
modifier = Modifier
115+
)
116+
}
117+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package org.schabi.newpipe.ui.components.common
2+
3+
import androidx.compose.foundation.layout.PaddingValues
4+
import androidx.compose.foundation.layout.RowScope
5+
import androidx.compose.foundation.layout.wrapContentWidth
6+
import androidx.compose.material3.Button
7+
import androidx.compose.material3.ButtonDefaults
8+
import androidx.compose.material3.MaterialTheme
9+
import androidx.compose.material3.Text
10+
import androidx.compose.runtime.Composable
11+
import androidx.compose.ui.Modifier
12+
import androidx.compose.ui.graphics.RectangleShape
13+
import androidx.compose.ui.tooling.preview.Preview
14+
import androidx.compose.ui.unit.dp
15+
import org.schabi.newpipe.ui.theme.AppTheme
16+
import org.schabi.newpipe.ui.theme.SizeTokens.SpacingMedium
17+
import org.schabi.newpipe.ui.theme.SizeTokens.SpacingSmall
18+
19+
@Composable
20+
fun ServiceColoredButton(
21+
onClick: () -> Unit,
22+
modifier: Modifier = Modifier,
23+
content: @Composable() RowScope.() -> Unit,
24+
) {
25+
Button(
26+
onClick = onClick,
27+
modifier = modifier.wrapContentWidth(),
28+
colors = ButtonDefaults.buttonColors(
29+
containerColor = MaterialTheme.colorScheme.error,
30+
contentColor = MaterialTheme.colorScheme.onError
31+
),
32+
contentPadding = PaddingValues(horizontal = SpacingMedium, vertical = SpacingSmall),
33+
shape = RectangleShape,
34+
elevation = ButtonDefaults.buttonElevation(
35+
defaultElevation = 8.dp,
36+
37+
),
38+
) {
39+
content()
40+
}
41+
}
42+
43+
@Preview
44+
@Composable
45+
fun ServiceColoredButtonPreview() {
46+
AppTheme {
47+
ServiceColoredButton(
48+
onClick = {},
49+
content = {
50+
Text("Button")
51+
}
52+
)
53+
}
54+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package org.schabi.newpipe.ui.components.video.comment
2+
3+
import android.util.Log
4+
import androidx.compose.foundation.layout.Box
5+
import androidx.compose.foundation.layout.fillMaxSize
6+
import androidx.compose.foundation.layout.fillMaxWidth
7+
import androidx.compose.foundation.layout.wrapContentHeight
8+
import androidx.compose.runtime.Composable
9+
import androidx.compose.runtime.SideEffect
10+
import androidx.compose.ui.Alignment
11+
import androidx.compose.ui.Modifier
12+
import androidx.compose.ui.input.nestedscroll.NestedScrollSource.Companion.SideEffect
13+
import androidx.compose.ui.tooling.preview.Preview
14+
import org.schabi.newpipe.error.ErrorInfo
15+
import org.schabi.newpipe.error.UserAction
16+
import org.schabi.newpipe.error.mapThrowableToErrorUiModel
17+
import org.schabi.newpipe.ui.UiModel.ErrorUiModel
18+
import org.schabi.newpipe.ui.components.common.ErrorPanel
19+
import java.io.IOException
20+
21+
@Composable
22+
fun CommentErrorHandler(
23+
throwable: Throwable,
24+
userAction: UserAction,
25+
onRetry: () -> Unit,
26+
onReport: (ErrorInfo) -> Unit
27+
) {
28+
SideEffect {
29+
Log.d("CommentErrorHandler", "⛔️ rendered for error: ${throwable.message}")
30+
}
31+
32+
val uiModel: ErrorUiModel = mapThrowableToErrorUiModel(throwable, userAction)
33+
val errorInfo = ErrorInfo(
34+
throwable = throwable,
35+
userAction = userAction,
36+
request = ""
37+
)
38+
39+
Box(
40+
modifier = Modifier.fillMaxSize(),
41+
contentAlignment = Alignment.Center
42+
) {
43+
ErrorPanel(
44+
spec = uiModel.spec,
45+
onRetry = onRetry,
46+
onReport = { onReport(errorInfo) },
47+
onOpenInBrowser = {},
48+
modifier = Modifier
49+
.fillMaxWidth()
50+
.wrapContentHeight()
51+
)
52+
}
53+
}
54+
55+
@Preview(showBackground = true)
56+
@Composable
57+
fun PreviewCommentErrorHandler() {
58+
CommentErrorHandler(
59+
throwable = IOException("No network"),
60+
userAction = UserAction.REQUESTED_COMMENTS,
61+
onRetry = {},
62+
onReport = {}
63+
)
64+
}

0 commit comments

Comments
 (0)