Compose GalleryCommentsScreen (#187)

* Initial Stub Compose GalleryCommentsScreen

* Initial Fab and edittext

* Refine comment edittext

* Support load more comments

* Animated loading state

* Refine, cleanup migrated things

* Initial comment dialog

* Refine

* Introduce buildAction dsl

* Impl voteComment

* Refine and cleanup migrated things

* Impl check vote status

* Refine

* Refine

* Padding a ime

* Support send commnet

* Cleanup all the code

* Let edittext and sendButton padding a navbar

* Let LazyColumn padding a ime

* Let LazyColumn contentPadding a edittext and fab

* Handle unresolved span with onCardClick

* Modify how LinkifyTextView works

* Adjust padding

* Adjust comment textstyle

---------

Co-authored-by: Lokesh Gidra <lokeshgidra@google.com>
This commit is contained in:
nullArrayListandLokesh Gidra authored and GitHub committed 2023-11-06 03:19:59 +00:00
1 parent 747fd23b96
commit 1238edfe2b
19 files changed
+590 -977

No files matched your search

@@ -19,7 +19,7 @@ import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
class GalleryCommentList(
var comments: Array<GalleryComment>,
data class GalleryCommentList(
val comments: List<GalleryComment>,
val hasMore: Boolean,
) : Parcelable
@@ -77,7 +77,7 @@ object GalleryDetailParser {
private val PATTERN_FAVORITE_SLOT =
Regex("/fav.png\\); background-position:0px -(\\d+)px")
private val EMPTY_GALLERY_TAG_GROUP_ARRAY = arrayOf<GalleryTagGroup>()
private val EMPTY_GALLERY_COMMENT_ARRAY = GalleryCommentList(arrayOf(), false)
private val EMPTY_GALLERY_COMMENT_ARRAY = GalleryCommentList(emptyList(), false)
private val WEB_COMMENT_DATE_FORMAT = DateTimeFormatter
.ofPattern("dd MMMM yyyy, HH:mm", Locale.US).withZone(ZoneOffset.UTC)
private const val OFFENSIVE_STRING =
@@ -438,7 +438,7 @@ object GalleryDetailParser {
},
chd!!,
)
GalleryCommentList(list.toTypedArray(), hasMore)
GalleryCommentList(list, hasMore)
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
e.printStackTrace()
@@ -352,8 +352,6 @@ private const val MAX_FAVNOTE_CHAR = 200
suspend fun DialogState.confirmRemoveDownload(info: GalleryInfo, onDismiss: () -> Unit = {}) {
var checked by mutableStateOf(Settings.removeImageFiles)
awaitPermissionOrCancel(
confirmText = android.R.string.ok,
dismissText = android.R.string.cancel,
title = R.string.download_remove_dialog_title,
onDismiss = onDismiss,
text = {
@@ -15,7 +15,6 @@
*/
package com.hippo.ehviewer.ui.legacy
import android.annotation.SuppressLint
import android.content.Context
import android.text.Spanned
import android.text.style.ClickableSpan
@@ -26,15 +25,8 @@ import androidx.appcompat.widget.AppCompatTextView
class LinkifyTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
private val onCurrentSpanUpdate: (ClickableSpan?) -> Unit,
) : AppCompatTextView(context, attrs) {
var currentSpan: ClickableSpan? = null
private set
fun clearCurrentSpan() {
currentSpan = null
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
// Let the parent or grandparent of TextView to handles click aciton.
// Otherwise click effect like ripple will not work, and if touch area
@@ -42,10 +34,8 @@ class LinkifyTextView @JvmOverloads constructor(
// onTouchEven must be called with MotionEvent.ACTION_DOWN for each touch
// action on it, so we analyze touched url here.
if (event.action == MotionEvent.ACTION_DOWN) {
currentSpan = null
onCurrentSpanUpdate(null)
if (text is Spanned) {
// Get this code from android.text.method.LinkMovementMethod.
// Work fine !
var x = event.x.toInt()
var y = event.y.toInt()
x -= totalPaddingLeft
@@ -58,7 +48,7 @@ class LinkifyTextView @JvmOverloads constructor(
val off = layout.getOffsetForHorizontal(line, x.toFloat())
val spans = (text as Spanned).getSpans(off, off, ClickableSpan::class.java)
if (spans.isNotEmpty()) {
currentSpan = spans[0]
onCurrentSpanUpdate(spans[0])
}
}
}
@@ -131,6 +131,7 @@ fun CookieSignInScene(navigator: DestinationsNavigator) {
EhCookieStore.signOut()
dialogState.awaitPermissionOrCancel(
confirmText = R.string.get_it,
showCancelButton = false,
title = R.string.sign_in_failed,
text = {
Text(
@@ -122,6 +122,7 @@ fun SignInScreen(navigator: DestinationsNavigator) {
focusManager.clearFocus()
dialogState.awaitPermissionOrCancel(
confirmText = R.string.get_it,
showCancelButton = false,
title = R.string.sign_in_failed,
text = {
Text(
@@ -0,0 +1,79 @@
package com.hippo.ehviewer.ui.main
import android.text.style.ClickableSpan
import android.text.style.URLSpan
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.constraintlayout.compose.ConstraintLayout
import com.hippo.ehviewer.R
import com.hippo.ehviewer.client.data.GalleryComment
import com.hippo.ehviewer.ui.legacy.LinkifyTextView
import com.hippo.ehviewer.util.ReadableTime
@Composable
fun GalleryCommentCard(
modifier: Modifier = Modifier,
comment: GalleryComment,
onCardClick: () -> Unit,
onUserClick: () -> Unit,
onUrlClick: (String) -> Unit,
update: LinkifyTextView.() -> Unit,
) = comment.run {
var currentSpan by remember(comment) { mutableStateOf<ClickableSpan?>(null) }
Card(
onClick = {
val span = currentSpan.apply { currentSpan = null }
if (span is URLSpan) {
onUrlClick(span.url)
} else {
onCardClick()
}
},
modifier = modifier,
) {
val keylineMargin = dimensionResource(id = R.dimen.keyline_margin)
ConstraintLayout(modifier = Modifier.padding(horizontal = keylineMargin, vertical = 8.dp).fillMaxWidth()) {
val (userRef, timeRef, commentRef) = createRefs()
ProvideTextStyle(value = MaterialTheme.typography.titleSmall) {
val userText = if (uploader) stringResource(id = R.string.comment_user_uploader, user.orEmpty()) else user.orEmpty()
Text(
text = userText,
modifier = Modifier.constrainAs(userRef) {
top.linkTo(parent.top)
start.linkTo(parent.start)
}.clickable(onClick = onUserClick),
)
Text(
text = ReadableTime.getTimeAgo(time),
modifier = Modifier.constrainAs(timeRef) {
top.linkTo(parent.top)
end.linkTo(parent.end)
},
)
}
AndroidView(
factory = { context -> LinkifyTextView(context) { currentSpan = it } },
modifier = Modifier.constrainAs(commentRef) {
start.linkTo(parent.start)
top.linkTo(userRef.bottom, margin = 8.dp)
},
update = update,
)
}
}
}
@@ -400,7 +400,6 @@ class FavoritesScene : SearchBarScene() {
lifecycleScope.launchIO {
dialogState.awaitPermissionOrCancel(
confirmText = R.string.delete,
dismissText = android.R.string.cancel,
title = R.string.delete_favorites_dialog_title,
onDismiss = { mAdapter!!.notifyItemChanged(position) },
) {
@@ -0,0 +1,436 @@
/*
* Copyright 2016 Hippo Seven
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.hippo.ehviewer.ui.scene
import android.content.Context
import android.graphics.Typeface
import android.os.Bundle
import android.text.SpannableStringBuilder
import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan
import android.text.style.StyleSpan
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text2.BasicTextField2
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Reply
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.currentRecomposeScope
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.text.inSpans
import androidx.core.text.parseAsHtml
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import com.hippo.ehviewer.R
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.EhFilter.remember
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.data.GalleryComment
import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.client.data.ListUrlBuilder
import com.hippo.ehviewer.dao.Filter
import com.hippo.ehviewer.dao.FilterMode
import com.hippo.ehviewer.ui.MainActivity
import com.hippo.ehviewer.ui.jumpToReaderByPage
import com.hippo.ehviewer.ui.legacy.CoilImageGetter
import com.hippo.ehviewer.ui.main.GalleryCommentCard
import com.hippo.ehviewer.ui.openBrowser
import com.hippo.ehviewer.ui.scene.GalleryListScene.Companion.toStartArgs
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.util.ExceptionUtils
import com.hippo.ehviewer.util.ReadableTime
import com.hippo.ehviewer.util.TextUrl
import com.hippo.ehviewer.util.addTextToClipboard
import com.hippo.ehviewer.util.findActivity
import com.hippo.ehviewer.util.getParcelableCompat
import com.ramcosta.composedestinations.annotation.Destination
import eu.kanade.tachiyomi.util.lang.launchIO
import kotlinx.coroutines.launch
import moe.tarsin.coroutines.runSuspendCatching
import rikka.core.res.resolveColor
interface ActionScope {
infix fun String.thenDo(that: suspend () -> Unit)
}
private inline fun buildAction(builder: ActionScope.() -> Unit) = buildList {
builder(object : ActionScope {
override fun String.thenDo(that: suspend () -> Unit) {
add(this to that)
}
})
}
private fun Context.generateComment(
textView: TextView,
comment: GalleryComment,
): CharSequence {
val sp = comment.comment.orEmpty().parseAsHtml(imageGetter = CoilImageGetter(textView))
val ssb = SpannableStringBuilder(sp)
if (0L != comment.id && 0 != comment.score) {
val score = comment.score
val scoreString = if (score > 0) "+$score" else score.toString()
ssb.append(" ").inSpans(
RelativeSizeSpan(0.8f),
StyleSpan(Typeface.BOLD),
ForegroundColorSpan(theme.resolveColor(android.R.attr.textColorSecondary)),
) {
append(scoreString)
}
}
if (comment.lastEdited != 0L) {
val str = getString(
R.string.last_edited,
ReadableTime.getTimeAgo(comment.lastEdited),
)
ssb.append("\n\n").inSpans(
RelativeSizeSpan(0.8f),
StyleSpan(Typeface.BOLD),
ForegroundColorSpan(theme.resolveColor(android.R.attr.textColorSecondary)),
) {
append(str)
}
}
return TextUrl.handleTextUrl(ssb)
}
@Destination
@Composable
fun GalleryCommentsScreen(galleryDetail: GalleryDetail, navigator: NavController) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val dialogState = LocalDialogState.current
val coroutineScope = rememberCoroutineScope()
var commenting by rememberSaveable { mutableStateOf(false) }
var userComment by rememberSaveable { mutableStateOf("") }
var comments by rememberSaveable { mutableStateOf(galleryDetail.comments) }
var refreshing by remember { mutableStateOf(false) }
val context = LocalContext.current
val density = LocalDensity.current
suspend fun refreshComment(showAll: Boolean) {
val url = EhUrl.getGalleryDetailUrl(galleryDetail.gid, galleryDetail.token, 0, showAll)
val detail = EhEngine.getGalleryDetail(url)
comments = detail.comments
}
val copyComment = stringResource(R.string.copy_comment_text)
val blockCommenter = stringResource(R.string.block_commenter)
val cancelVoteUp = stringResource(R.string.cancel_vote_up)
val cancelVoteDown = stringResource(R.string.cancel_vote_down)
val voteUp = stringResource(R.string.vote_up)
val voteDown = stringResource(R.string.vote_down)
val checkVoteStatus = stringResource(R.string.check_vote_status)
val editCommentSuccess = stringResource(R.string.edit_comment_successfully)
val commentSuccess = stringResource(R.string.comment_successfully)
val editCommentFail = stringResource(R.string.edit_comment_failed)
val commentFail = stringResource(R.string.comment_failed)
suspend fun Context.sendComment() {
commenting = false
val url = EhUrl.getGalleryDetailUrl(galleryDetail.gid, galleryDetail.token, 0, false)
runSuspendCatching {
EhEngine.commentGallery(url, userComment, null)
}.onSuccess {
findActivity<MainActivity>().showTip(
if (false) editCommentSuccess else commentSuccess,
BaseScene.LENGTH_SHORT,
)
userComment = ""
comments = it
}.onFailure {
val text = if (false) editCommentFail else commentFail
findActivity<MainActivity>().showTip(
text + "\n" + ExceptionUtils.getReadableString(it),
BaseScene.LENGTH_LONG,
)
}
}
suspend fun Context.showFilterCommenter(comment: GalleryComment) {
val commenter = comment.user ?: return
dialogState.awaitPermissionOrCancel { Text(text = stringResource(R.string.filter_the_commenter, commenter)) }
Filter(FilterMode.COMMENTER, commenter).remember()
comments = comments.copy(comments = comments.comments.filter { it == comment })
findActivity<MainActivity>().showTip(R.string.filter_added, BaseScene.LENGTH_SHORT)
}
suspend fun showCommentVoteStatus(comment: GalleryComment) {
val statusStr = comment.voteState ?: return
val data = statusStr.split(',').map {
val str = it.trim()
val index = str.lastIndexOf(' ')
if (index < 0) {
str to ""
} else {
str.substring(0, index).trim() to str.substring(index + 1).trim()
}
}
// Wait cancellation
dialogState.showNoButton<Unit> {
Column {
data.forEach { (name, vote) ->
ListItem(
headlineContent = {
Text(text = name)
},
trailingContent = {
Text(text = vote)
},
)
}
}
}
}
BackHandler(commenting) {
commenting = false
}
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
TopAppBar(
title = { Text(text = stringResource(id = R.string.gallery_comments)) },
navigationIcon = {
IconButton(onClick = { navigator.popBackStack() }) {
Icon(imageVector = Icons.AutoMirrored.Default.ArrowBack, contentDescription = null)
}
},
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
if (!commenting) {
FloatingActionButton(onClick = { commenting = true }) {
Icon(imageVector = Icons.AutoMirrored.Default.Reply, contentDescription = null)
}
}
},
) { paddingValues ->
val keylineMargin = dimensionResource(id = R.dimen.keyline_margin)
var editTextMeasured by remember { mutableStateOf(0.dp) }
Box(modifier = Modifier.fillMaxSize().imePadding()) {
val additionalPadding = if (commenting) {
editTextMeasured
} else {
if (!comments.hasMore) {
16.dp + 56.dp // Fab space + Fab size
} else {
16.dp // Fab space
}
}
LazyColumn(
modifier = Modifier.padding(horizontal = keylineMargin),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding() + additionalPadding,
),
) {
items(comments.comments) { item ->
val recomposeScope = currentRecomposeScope
suspend fun Context.voteComment(comment: GalleryComment, isUp: Boolean) {
galleryDetail.runSuspendCatching {
EhEngine.voteComment(apiUid, apiKey, gid, token, comment.id, if (isUp) 1 else -1)
}.onSuccess { result ->
findActivity<MainActivity>().showTip(
if (isUp) (if (0 != result.vote) R.string.vote_up_successfully else R.string.cancel_vote_up_successfully) else if (0 != result.vote) R.string.vote_down_successfully else R.string.cancel_vote_down_successfully,
BaseScene.LENGTH_SHORT,
)
comment.score = result.score
if (isUp) {
comment.voteUpEd = 0 != result.vote
comment.voteDownEd = false
} else {
comment.voteDownEd = 0 != result.vote
comment.voteUpEd = false
}
recomposeScope.invalidate()
}.onFailure {
findActivity<MainActivity>().showTip(R.string.vote_failed, BaseScene.LENGTH_LONG)
}
}
suspend fun Context.doCommentAction(comment: GalleryComment) {
val actions = buildAction {
copyComment thenDo { findActivity<MainActivity>().addTextToClipboard(comment.comment.orEmpty().parseAsHtml()) }
if (!comment.uploader && !comment.editable) {
blockCommenter thenDo { showFilterCommenter(comment) }
}
if (comment.voteUpAble) {
(if (comment.voteUpEd) cancelVoteUp else voteUp) thenDo { voteComment(comment, true) }
}
if (comment.voteDownAble) {
(if (comment.voteDownEd) cancelVoteDown else voteDown) thenDo { voteComment(comment, false) }
}
if (!comment.voteState.isNullOrEmpty()) {
checkVoteStatus thenDo { showCommentVoteStatus(comment) }
}
}
dialogState.showSelectItem(*actions.toTypedArray()).invoke()
}
GalleryCommentCard(
comment = item,
onUserClick = {
val lub = ListUrlBuilder(
mode = ListUrlBuilder.MODE_UPLOADER,
mKeyword = item.user,
)
navigator.navAnimated(R.id.galleryListScene, lub.toStartArgs(), true)
},
onCardClick = {
coroutineScope.launch {
context.doCommentAction(item)
}
},
onUrlClick = {
val activity = context.findActivity<MainActivity>()
if (!activity.jumpToReaderByPage(it, galleryDetail)) {
if (!navigator.navWithUrl(it)) {
activity.openBrowser(it)
}
}
},
) {
maxLines = 5
text = context.generateComment(this, item)
}
}
if (comments.hasMore) {
item {
AnimatedVisibility(refreshing) {
Box(modifier = Modifier.fillMaxWidth()) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center).padding(keylineMargin),
)
}
}
AnimatedVisibility(!refreshing) {
TextButton(
onClick = {
coroutineScope.launchIO {
refreshing = true
runSuspendCatching {
refreshComment(true)
}
refreshing = false
}
},
modifier = Modifier.fillMaxWidth().padding(keylineMargin),
) {
Text(text = stringResource(id = R.string.click_more_comments))
}
}
}
}
}
if (commenting) {
Surface(
modifier = Modifier.align(Alignment.BottomCenter).onGloballyPositioned { coordinates ->
editTextMeasured = with(density) { coordinates.size.height.toDp() }
},
color = MaterialTheme.colorScheme.primaryContainer,
) {
Row(modifier = Modifier.fillMaxWidth().navigationBarsPadding()) {
BasicTextField2(
value = userComment,
onValueChange = { userComment = it },
modifier = Modifier.weight(1f).padding(keylineMargin),
textStyle = MaterialTheme.typography.bodyLarge,
)
IconButton(
onClick = {
coroutineScope.launchIO {
context.sendComment()
}
},
modifier = Modifier.align(Alignment.CenterVertically).padding(16.dp),
) {
Icon(
imageVector = Icons.AutoMirrored.Default.Send,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
}
}
}
}
}
class GalleryCommentsFragment : BaseScene() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
return ComposeWithMD3 {
val galleryDetail = remember { requireArguments().getParcelableCompat<GalleryDetail>(KEY_GALLERY_DETAIL)!! }
val navController = remember { findNavController() }
GalleryCommentsScreen(galleryDetail = galleryDetail, navigator = navController)
}
}
companion object {
const val KEY_GALLERY_DETAIL = "gallery_detail"
}
}
@@ -1,894 +0,0 @@
/*
* Copyright 2016 Hippo Seven
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.hippo.ehviewer.ui.scene
import android.animation.Animator
import android.annotation.SuppressLint
import android.content.Context
import android.content.DialogInterface
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.os.Looper
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.style.CharacterStyle
import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan
import android.text.style.StrikethroughSpan
import android.text.style.StyleSpan
import android.text.style.URLSpan
import android.text.style.UnderlineSpan
import android.util.Log
import android.view.ActionMode
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewAnimationUtils
import android.view.ViewGroup
import android.widget.TextView
import androidx.activity.OnBackPressedCallback
import androidx.core.content.ContextCompat
import androidx.core.text.getSpans
import androidx.core.text.inSpans
import androidx.core.text.parseAsHtml
import androidx.core.text.set
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsAnimationCompat
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
import androidx.viewbinding.ViewBinding
import com.hippo.ehviewer.R
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.EhFilter.remember
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.data.GalleryComment
import com.hippo.ehviewer.client.data.GalleryCommentList
import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.client.data.ListUrlBuilder
import com.hippo.ehviewer.client.parser.VoteCommentResult
import com.hippo.ehviewer.dao.Filter
import com.hippo.ehviewer.dao.FilterMode
import com.hippo.ehviewer.databinding.ItemDrawerFavoritesBinding
import com.hippo.ehviewer.databinding.ItemGalleryCommentBinding
import com.hippo.ehviewer.databinding.ItemGalleryCommentMoreBinding
import com.hippo.ehviewer.databinding.ItemGalleryCommentProgressBinding
import com.hippo.ehviewer.databinding.SceneGalleryCommentsBinding
import com.hippo.ehviewer.ui.jumpToReaderByPage
import com.hippo.ehviewer.ui.legacy.BaseDialogBuilder
import com.hippo.ehviewer.ui.legacy.CoilImageGetter
import com.hippo.ehviewer.ui.legacy.EditTextDialogBuilder
import com.hippo.ehviewer.ui.legacy.ViewTransition
import com.hippo.ehviewer.ui.legacy.WindowInsetsAnimationHelper
import com.hippo.ehviewer.ui.openBrowser
import com.hippo.ehviewer.ui.scene.GalleryListScene.Companion.toStartArgs
import com.hippo.ehviewer.util.AnimationUtils
import com.hippo.ehviewer.util.ExceptionUtils
import com.hippo.ehviewer.util.IntList
import com.hippo.ehviewer.util.ReadableTime
import com.hippo.ehviewer.util.SimpleAnimatorListener
import com.hippo.ehviewer.util.TextUrl
import com.hippo.ehviewer.util.addTextToClipboard
import com.hippo.ehviewer.util.applyNavigationBarsPadding
import com.hippo.ehviewer.util.getParcelableCompat
import com.hippo.ehviewer.util.toBBCode
import dev.chrisbanes.insetter.applyInsetter
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withUIContext
import kotlin.math.hypot
import moe.tarsin.coroutines.runSuspendCatching
import rikka.core.res.resolveColor
class GalleryCommentsScene : BaseToolbarScene(), View.OnClickListener, OnRefreshListener {
private var _binding: SceneGalleryCommentsBinding? = null
private val binding get() = _binding!!
private val callback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
if (!mInAnimation && binding.editPanel.visibility == View.VISIBLE) {
hideEditPanel(true)
}
}
}
private var mGalleryDetail: GalleryDetail? = null
private var mAdapter: CommentAdapter? = null
private var mViewTransition: ViewTransition? = null
private var mSendDrawable: Drawable? = null
private var mPencilDrawable: Drawable? = null
private var mCommentId: Long = 0
private var mInAnimation = false
private var mShowAllComments = false
private var mRefreshingComments = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requireActivity().onBackPressedDispatcher.addCallback(callback)
if (savedInstanceState == null) {
onInit()
} else {
onRestore(savedInstanceState)
}
}
private fun handleArgs(args: Bundle?) {
if (args == null) {
return
}
mGalleryDetail = args.getParcelableCompat(KEY_GALLERY_DETAIL)
mShowAllComments =
mGalleryDetail != null && true && !mGalleryDetail!!.comments.hasMore
}
private fun onInit() {
handleArgs(arguments)
}
private fun onRestore(savedInstanceState: Bundle) {
mGalleryDetail = savedInstanceState.getParcelableCompat(KEY_GALLERY_DETAIL)
mShowAllComments = mGalleryDetail != null && !mGalleryDetail!!.comments.hasMore
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putParcelable(KEY_GALLERY_DETAIL, mGalleryDetail)
}
override fun onCreateViewWithToolbar(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
_binding = SceneGalleryCommentsBinding.inflate(inflater, container!!)
setLiftOnScrollTargetView(binding.recyclerView)
val tip = binding.tip
ViewCompat.setWindowInsetsAnimationCallback(
binding.root,
WindowInsetsAnimationHelper(
WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_STOP,
binding.editPanel,
binding.fabLayout,
),
)
binding.refreshLayout.setOnRefreshListener(this)
val context = requireContext()
val drawable = ContextCompat.getDrawable(context, R.drawable.big_sad_pandroid)
drawable!!.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
tip.setCompoundDrawables(null, drawable, null, null)
mSendDrawable = ContextCompat.getDrawable(context, R.drawable.v_send_dark_x24)
mPencilDrawable = ContextCompat.getDrawable(context, R.drawable.v_pencil_dark_x24)
mAdapter = CommentAdapter()
binding.recyclerView.adapter = mAdapter
binding.recyclerView.layoutManager = LinearLayoutManager(
context,
RecyclerView.VERTICAL,
false,
)
binding.recyclerView.setHasFixedSize(true)
// Cancel change animator
val itemAnimator = binding.recyclerView.itemAnimator
if (itemAnimator is DefaultItemAnimator) {
itemAnimator.supportsChangeAnimations = false
}
binding.send.setOnClickListener(this)
binding.editText.customSelectionActionModeCallback = object : ActionMode.Callback {
override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
requireActivity().menuInflater.inflate(R.menu.context_comment, menu)
return true
}
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean {
return true
}
override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean {
item?.let {
val text = binding.editText.editableText
val start = binding.editText.selectionStart
val end = binding.editText.selectionEnd
when (item.itemId) {
R.id.action_bold -> text[start, end] = StyleSpan(Typeface.BOLD)
R.id.action_italic -> text[start, end] = StyleSpan(Typeface.ITALIC)
R.id.action_underline -> text[start, end] = UnderlineSpan()
R.id.action_strikethrough -> text[start, end] = StrikethroughSpan()
R.id.action_url -> {
val oldSpans = text.getSpans<URLSpan>(start, end)
var oldUrl = "https://"
oldSpans.forEach {
if (!it.url.isNullOrEmpty()) {
oldUrl = it.url
}
}
val builder = EditTextDialogBuilder(
context,
oldUrl,
getString(R.string.format_url),
)
builder.setTitle(getString(R.string.format_url))
builder.setPositiveButton(android.R.string.ok, null)
val dialog = builder.show()
val button: View? = dialog.getButton(DialogInterface.BUTTON_POSITIVE)
button?.setOnClickListener(
View.OnClickListener {
val url = builder.text.trim()
if (url.isEmpty()) {
builder.setError(getString(R.string.text_is_empty))
return@OnClickListener
} else {
builder.setError(null)
}
text.clearSpan(start, end, true)
text[start, end] = URLSpan(url)
dialog.dismiss()
},
)
}
R.id.action_clear -> {
text.clearSpan(start, end, false)
}
else -> return false
}
mode?.finish()
}
return true
}
override fun onDestroyActionMode(mode: ActionMode?) {
}
}
binding.fab.setOnClickListener(this)
mViewTransition = ViewTransition(binding.refreshLayout, tip)
updateView(false)
binding.editPanel.applyInsetter {
type(ime = true, navigationBars = true) {
padding()
}
}
binding.recyclerView.applyInsetter {
type(ime = true, navigationBars = true) {
padding()
}
}
binding.fabLayout.applyNavigationBarsPadding()
return binding.root
}
fun Spannable.clearSpan(start: Int, end: Int, url: Boolean) {
val spans = if (url) getSpans<URLSpan>(start, end) else getSpans<CharacterStyle>(start, end)
spans.forEach {
val spanStart = getSpanStart(it)
val spanEnd = getSpanEnd(it)
removeSpan(it)
if (spanStart < start) {
this[spanStart, start] = it
}
if (spanEnd > end) {
this[end, spanEnd] = it
}
}
}
override fun onDestroyView() {
super.onDestroyView()
callback.remove()
binding.recyclerView.stopScroll()
mAdapter = null
mViewTransition = null
_binding = null
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setTitle(R.string.gallery_comments)
setNavigationIcon(R.drawable.v_arrow_left_dark_x24)
}
private fun showFilterCommenterDialog(commenter: String?, position: Int) {
val context = context
if (context == null || commenter == null) {
return
}
BaseDialogBuilder(context)
.setMessage(getString(R.string.filter_the_commenter, commenter))
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
Filter(FilterMode.COMMENTER, commenter).remember()
hideComment(position)
showTip(R.string.filter_added, LENGTH_SHORT)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun hideComment(position: Int) {
if (mGalleryDetail == null) {
return
}
val oldCommentsList = mGalleryDetail!!.comments.comments
val newCommentsList = arrayOfNulls<GalleryComment>(
oldCommentsList.size - 1,
)
var i = 0
var j = 0
while (i < oldCommentsList.size) {
if (i != position) {
newCommentsList[j] = oldCommentsList[i]
j++
}
i++
}
mGalleryDetail!!.comments.comments = newCommentsList.requireNoNulls()
mAdapter!!.notifyDataSetChanged()
updateView(true)
}
private fun voteComment(id: Long, vote: Int) {
val gd = mGalleryDetail ?: return
lifecycleScope.launchIO {
runSuspendCatching {
EhEngine.voteComment(gd.apiUid, gd.apiKey, gd.gid, gd.token, id, vote)
}.onSuccess { result ->
val voteUp = vote > 0
showTip(
if (voteUp) (if (0 != result.vote) R.string.vote_up_successfully else R.string.cancel_vote_up_successfully) else if (0 != result.vote) R.string.vote_down_successfully else R.string.cancel_vote_down_successfully,
LENGTH_SHORT,
)
withUIContext { onVoteCommentSuccess(result, voteUp) }
}.onFailure {
showTip(R.string.vote_failed, LENGTH_LONG)
}
}
}
@SuppressLint("InflateParams")
fun showVoteStatusDialog(context: Context, voteStatus: String) {
val temp = voteStatus.split(',')
val length = temp.size
val userArray = arrayOfNulls<String>(length)
val voteArray = arrayOfNulls<String>(length)
for (i in 0 until length) {
val str = temp[i].trim()
val index = str.lastIndexOf(' ')
if (index < 0) {
Log.d(TAG, "Something wrong happened about vote state")
userArray[i] = str
voteArray[i] = ""
} else {
userArray[i] = str.substring(0, index).trim()
voteArray[i] = str.substring(index + 1).trim()
}
}
val builder = BaseDialogBuilder(context)
val builderContext = builder.context
val inflater = LayoutInflater.from(builderContext)
val rv = inflater.inflate(R.layout.dialog_recycler_view, null) as RecyclerView
rv.adapter = object : RecyclerView.Adapter<VoteHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VoteHolder {
return VoteHolder(ItemDrawerFavoritesBinding.inflate(inflater, parent, false))
}
override fun onBindViewHolder(holder: VoteHolder, position: Int) {
holder.bind(userArray[position], voteArray[position])
}
override fun getItemCount(): Int {
return length
}
}
rv.layoutManager = LinearLayoutManager(builderContext)
rv.clipToPadding = false
builder.setView(rv).show()
}
private fun showCommentDialog(position: Int, text: CharSequence) {
val context = context
if (context == null || mGalleryDetail == null || position >= mGalleryDetail!!.comments.comments.size || position < 0) {
return
}
val comment = mGalleryDetail!!.comments.comments[position]
val menu: MutableList<String> = ArrayList()
val menuId = IntList()
val resources = context.resources
menu.add(resources.getString(R.string.copy_comment_text))
menuId.add(R.id.copy)
if (!comment.uploader && !comment.editable) {
menu.add(resources.getString(R.string.block_commenter))
menuId.add(R.id.block_commenter)
}
if (comment.editable) {
menu.add(resources.getString(R.string.edit_comment))
menuId.add(R.id.edit_comment)
}
if (comment.voteUpAble) {
menu.add(resources.getString(if (comment.voteUpEd) R.string.cancel_vote_up else R.string.vote_up))
menuId.add(R.id.vote_up)
}
if (comment.voteDownAble) {
menu.add(resources.getString(if (comment.voteDownEd) R.string.cancel_vote_down else R.string.vote_down))
menuId.add(R.id.vote_down)
}
if (!comment.voteState.isNullOrEmpty()) {
menu.add(resources.getString(R.string.check_vote_status))
menuId.add(R.id.check_vote_status)
}
BaseDialogBuilder(context)
.setItems(menu.toTypedArray()) { _: DialogInterface?, which: Int ->
if (which < 0 || which >= menuId.size) {
return@setItems
}
when (menuId[which]) {
R.id.copy -> requireActivity().addTextToClipboard(text)
R.id.block_commenter -> showFilterCommenterDialog(comment.user, position)
R.id.vote_up -> voteComment(comment.id, 1)
R.id.vote_down -> voteComment(comment.id, -1)
R.id.check_vote_status -> showVoteStatusDialog(context, comment.voteState!!)
R.id.edit_comment -> {
prepareEditComment(comment.id, text)
if (!mInAnimation && binding.editPanel.visibility != View.VISIBLE) {
showEditPanel(true)
}
}
}
}.show()
}
fun onItemClick(parent: RecyclerView?, view2: View?, position: Int): Boolean {
val activity = mainActivity ?: return false
val holder = parent!!.getChildViewHolder(view2!!)
if (holder is ActualCommentHolder) {
val span = holder.binding.comment.currentSpan
holder.binding.comment.clearCurrentSpan()
val detail = mGalleryDetail ?: return false
if (span is URLSpan) {
if (!activity.jumpToReaderByPage(span.url, detail)) {
if (!findNavController().navWithUrl(span.url)) {
activity.openBrowser(span.url)
}
}
} else {
showCommentDialog(position, holder.sp)
}
} else if (holder is MoreCommentHolder && !mRefreshingComments && mAdapter != null) {
mRefreshingComments = true
mShowAllComments = true
mAdapter!!.notifyItemChanged(position)
galleryDetailUrl?.let { refreshComment(it) }
}
return true
}
private fun refreshComment(url: String) {
lifecycleScope.launchIO {
runSuspendCatching {
EhEngine.getGalleryDetail(url)
}.onSuccess {
withUIContext {
onRefreshGallerySuccess(it.comments)
}
}.onFailure {
withUIContext {
onRefreshGalleryFailure()
}
}
}
}
private fun updateView(animation: Boolean) {
if (null == mViewTransition) {
return
}
if (mGalleryDetail == null || mGalleryDetail!!.comments.comments.isEmpty()) {
mViewTransition!!.showView(1, animation)
} else {
mViewTransition!!.showView(0, animation)
}
}
private fun prepareNewComment() {
mCommentId = 0
binding.send.setImageDrawable(mSendDrawable)
}
private fun prepareEditComment(commentId: Long, text: CharSequence) {
mCommentId = commentId
binding.editText.setText(text)
binding.send.setImageDrawable(mPencilDrawable)
}
private fun showEditPanelWithAnimation() {
mInAnimation = true
binding.fab.translationX = 0.0f
binding.fab.translationY = 0.0f
binding.fab.scaleX = 1.0f
binding.fab.scaleY = 1.0f
val fabEndX = binding.editPanel.left + binding.editPanel.width / 2 - binding.fab.width / 2
val fabEndY = binding.editPanel.top + binding.editPanel.height / 2 - binding.fab.height / 2
binding.fab.animate().x(fabEndX.toFloat()).y(fabEndY.toFloat()).scaleX(0.0f).scaleY(0.0f)
.setInterpolator(AnimationUtils.SLOW_FAST_SLOW_INTERPOLATOR)
.setDuration(300L).setListener(object : SimpleAnimatorListener() {
override fun onAnimationEnd(animation: Animator) {
(binding.fab as View).visibility = View.INVISIBLE
binding.editPanel.visibility = View.VISIBLE
val halfW = binding.editPanel.width / 2
val halfH = binding.editPanel.height / 2
val animator = ViewAnimationUtils.createCircularReveal(
binding.editPanel,
halfW,
halfH,
0f,
hypot(halfW.toDouble(), halfH.toDouble()).toFloat(),
).setDuration(300L)
animator.addListener(object : SimpleAnimatorListener() {
override fun onAnimationEnd(animation: Animator) {
mInAnimation = false
}
})
animator.start()
}
}).start()
}
private fun showEditPanel(animation: Boolean) {
callback.isEnabled = true
if (animation) {
showEditPanelWithAnimation()
} else {
(binding.fab as View).visibility = View.INVISIBLE
binding.editPanel.visibility = View.VISIBLE
}
}
private fun hideEditPanelWithAnimation() {
mInAnimation = true
val halfW = binding.editPanel.width / 2
val halfH = binding.editPanel.height / 2
val animator = ViewAnimationUtils.createCircularReveal(
binding.editPanel,
halfW,
halfH,
hypot(halfW.toDouble(), halfH.toDouble()).toFloat(),
0.0f,
).setDuration(300L)
animator.addListener(object : SimpleAnimatorListener() {
override fun onAnimationEnd(animation: Animator) {
if (Looper.myLooper() != Looper.getMainLooper()) {
// Some devices may run this block in non-UI thread.
// It might be a bug of Android OS.
// Check it here to avoid crash.
return
}
binding.editPanel.visibility = View.GONE
(binding.fab as View).visibility = View.VISIBLE
val fabStartX =
binding.editPanel.left + binding.editPanel.width / 2 - binding.fab.width / 2
val fabStartY =
binding.editPanel.top + binding.editPanel.height / 2 - binding.fab.height / 2
binding.fab.x = fabStartX.toFloat()
binding.fab.y = fabStartY.toFloat()
binding.fab.scaleX = 0.0f
binding.fab.scaleY = 0.0f
binding.fab.rotation = -45.0f
binding.fab.animate().translationX(0.0f).translationY(0.0f).scaleX(1.0f)
.scaleY(1.0f)
.rotation(0.0f)
.setInterpolator(AnimationUtils.SLOW_FAST_SLOW_INTERPOLATOR)
.setDuration(300L).setListener(object : SimpleAnimatorListener() {
override fun onAnimationEnd(animation: Animator) {
mInAnimation = false
}
}).start()
}
})
animator.start()
}
private fun hideEditPanel(animation: Boolean) {
callback.isEnabled = false
hideSoftInput()
if (animation) {
hideEditPanelWithAnimation()
} else {
(binding.fab as View).visibility = View.VISIBLE
binding.editPanel.visibility = View.INVISIBLE
}
}
private val galleryDetailUrl: String?
get() = if (mGalleryDetail != null && mGalleryDetail!!.gid != -1L && mGalleryDetail!!.token != null) {
EhUrl.getGalleryDetailUrl(
mGalleryDetail!!.gid,
mGalleryDetail!!.token,
0,
mShowAllComments,
)
} else {
null
}
override fun onClick(v: View) {
val context = context
val activity = mainActivity
if (null == context || null == activity) {
return
}
if (binding.fab === v) {
if (!mInAnimation) {
prepareNewComment()
showEditPanel(true)
}
} else if (binding.send === v) {
if (!mInAnimation) {
val comment = binding.editText.text?.toBBCode()?.takeIf { it.isNotBlank() } ?: return
val url = galleryDetailUrl ?: return
lifecycleScope.launchIO {
runSuspendCatching {
EhEngine.commentGallery(
url,
comment,
if (mCommentId != 0L) mCommentId.toString() else null,
)
}.onSuccess {
showTip(
if (mCommentId != 0L) R.string.edit_comment_successfully else R.string.comment_successfully,
LENGTH_SHORT,
)
withUIContext {
onCommentGallerySuccess(it)
}
}.onFailure {
showTip(
"""
${getString(if (mCommentId != 0L) R.string.edit_comment_failed else R.string.comment_failed)}
${ExceptionUtils.getReadableString(it)}
""".trimIndent(),
LENGTH_LONG,
)
}
}
hideSoftInput()
hideEditPanel(true)
}
}
}
private fun onRefreshGallerySuccess(result: GalleryCommentList) {
if (mGalleryDetail == null || mAdapter == null) {
return
}
binding.refreshLayout.isRefreshing = false
mRefreshingComments = false
mGalleryDetail!!.comments = result
mAdapter!!.notifyDataSetChanged()
updateView(true)
}
private fun onRefreshGalleryFailure() {
if (mAdapter == null) {
return
}
binding.refreshLayout.isRefreshing = false
mRefreshingComments = false
val position = mAdapter!!.itemCount - 1
if (position >= 0) {
mAdapter!!.notifyItemChanged(position)
}
}
private fun onCommentGallerySuccess(result: GalleryCommentList) {
if (mGalleryDetail == null || mAdapter == null) {
return
}
mGalleryDetail!!.comments = result
mAdapter!!.notifyDataSetChanged()
// Remove text
binding.editText.setText("")
updateView(true)
}
private fun onVoteCommentSuccess(result: VoteCommentResult, voteUp: Boolean) {
if (mAdapter == null) {
return
}
var position = -1
var i = 0
val n = mGalleryDetail!!.comments.comments.size
while (i < n) {
val comment = mGalleryDetail!!.comments.comments[i]
if (comment.id == result.id) {
position = i
break
}
i++
}
if (-1 == position) {
Log.d(TAG, "Can't find comment with id " + result.id)
return
}
// Update comment
val comment = mGalleryDetail!!.comments.comments[position]
comment.score = result.score
if (voteUp) {
comment.voteUpEd = 0 != result.vote
comment.voteDownEd = false
} else {
comment.voteDownEd = 0 != result.vote
comment.voteUpEd = false
}
mAdapter!!.notifyItemChanged(position)
}
override fun onRefresh() {
if (!mRefreshingComments && mAdapter != null) {
mRefreshingComments = true
galleryDetailUrl?.let { refreshComment(it) }
}
}
private class VoteHolder(private val binding: ItemDrawerFavoritesBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(user: String?, vote: String?) {
binding.key.text = user
binding.value.text = vote
}
}
private abstract class CommentHolder(binding: ViewBinding) :
RecyclerView.ViewHolder(binding.root)
private class MoreCommentHolder(binding: ItemGalleryCommentMoreBinding) : CommentHolder(binding)
private class ProgressCommentHolder(binding: ItemGalleryCommentProgressBinding) :
CommentHolder(binding)
private inner class ActualCommentHolder(val binding: ItemGalleryCommentBinding) :
CommentHolder(binding) {
lateinit var sp: CharSequence
private fun generateComment(
context: Context,
textView: TextView,
comment: GalleryComment,
): CharSequence {
sp = comment.comment.orEmpty().parseAsHtml(imageGetter = CoilImageGetter(textView))
val ssb = SpannableStringBuilder(sp)
if (0L != comment.id && 0 != comment.score) {
val score = comment.score
val scoreString = if (score > 0) "+$score" else score.toString()
ssb.append(" ").inSpans(
RelativeSizeSpan(0.8f),
StyleSpan(Typeface.BOLD),
ForegroundColorSpan(theme.resolveColor(android.R.attr.textColorSecondary)),
) {
append(scoreString)
}
}
if (comment.lastEdited != 0L) {
val str = context.getString(
R.string.last_edited,
ReadableTime.getTimeAgo(comment.lastEdited),
)
ssb.append("\n\n").inSpans(
RelativeSizeSpan(0.8f),
StyleSpan(Typeface.BOLD),
ForegroundColorSpan(theme.resolveColor(android.R.attr.textColorSecondary)),
) {
append(str)
}
}
return TextUrl.handleTextUrl(ssb)
}
fun bind(value: GalleryComment) {
binding.run {
user.text = value.user?.let {
if (value.uploader) getString(R.string.comment_user_uploader, it) else it
}
user.setOnClickListener {
value.user?.let {
val lub = ListUrlBuilder().apply {
mode = ListUrlBuilder.MODE_UPLOADER
keyword = it
}
navAnimated(R.id.galleryListScene, lub.toStartArgs(), true)
}
}
time.text = ReadableTime.getTimeAgo(value.time)
comment.text = generateComment(binding.comment.context, binding.comment, value)
}
}
}
private inner class CommentAdapter : RecyclerView.Adapter<CommentHolder>() {
private val mInflater: LayoutInflater = layoutInflater
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CommentHolder {
return when (viewType) {
TYPE_COMMENT -> ActualCommentHolder(
ItemGalleryCommentBinding.inflate(mInflater, parent, false),
)
TYPE_MORE -> MoreCommentHolder(
ItemGalleryCommentMoreBinding.inflate(mInflater, parent, false),
)
TYPE_PROGRESS -> ProgressCommentHolder(
ItemGalleryCommentProgressBinding.inflate(mInflater, parent, false),
)
else -> throw IllegalStateException("Invalid view type: $viewType")
}
}
override fun onBindViewHolder(holder: CommentHolder, position: Int) {
val context = context
if (context == null || mGalleryDetail == null) {
return
}
holder.itemView.setOnClickListener {
onItemClick(
binding.recyclerView,
holder.itemView,
position,
)
}
holder.itemView.isClickable = true
holder.itemView.isFocusable = true
if (holder is ActualCommentHolder) {
holder.bind(mGalleryDetail!!.comments.comments[position])
}
}
override fun getItemCount(): Int {
return if (mGalleryDetail == null) {
0
} else if (mGalleryDetail!!.comments.hasMore) {
mGalleryDetail!!.comments.comments.size + 1
} else {
mGalleryDetail!!.comments.comments.size
}
}
override fun getItemViewType(position: Int): Int {
return if (position >= mGalleryDetail!!.comments.comments.size) {
if (mRefreshingComments) TYPE_PROGRESS else TYPE_MORE
} else {
TYPE_COMMENT
}
}
}
companion object {
val TAG: String = GalleryCommentsScene::class.java.simpleName
const val KEY_API_UID = "api_uid"
const val KEY_API_KEY = "api_key"
const val KEY_GID = "gid"
const val KEY_TOKEN = "token"
const val KEY_COMMENT_LIST = "comment_list"
const val KEY_GALLERY_DETAIL = "gallery_detail"
private const val TYPE_COMMENT = 0
private const val TYPE_MORE = 1
private const val TYPE_PROGRESS = 2
}
}
@@ -21,7 +21,6 @@ import android.app.Activity
import android.app.Dialog
import android.app.DownloadManager
import android.content.DialogInterface
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.os.Environment
@@ -91,7 +90,7 @@ import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidViewBinding
import androidx.core.os.bundleOf
import androidx.core.text.parseAsHtml
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
@@ -129,7 +128,6 @@ import com.hippo.ehviewer.dao.FilterMode
import com.hippo.ehviewer.databinding.DialogArchiveListBinding
import com.hippo.ehviewer.databinding.DialogRateBinding
import com.hippo.ehviewer.databinding.DialogTorrentListBinding
import com.hippo.ehviewer.databinding.ItemGalleryCommentBinding
import com.hippo.ehviewer.download.DownloadManager as EhDownloadManager
import com.hippo.ehviewer.ktbuilder.imageRequest
import com.hippo.ehviewer.spider.SpiderQueen
@@ -138,12 +136,14 @@ import com.hippo.ehviewer.ui.CommonOperations
import com.hippo.ehviewer.ui.GalleryInfoBottomSheet
import com.hippo.ehviewer.ui.MainActivity
import com.hippo.ehviewer.ui.getFavoriteIcon
import com.hippo.ehviewer.ui.jumpToReaderByPage
import com.hippo.ehviewer.ui.legacy.BaseDialogBuilder
import com.hippo.ehviewer.ui.legacy.CheckBoxDialogBuilder
import com.hippo.ehviewer.ui.legacy.CoilImageGetter
import com.hippo.ehviewer.ui.legacy.GalleryRatingBar.OnUserRateListener
import com.hippo.ehviewer.ui.legacy.calculateSuitableSpanCount
import com.hippo.ehviewer.ui.main.EhPreviewItem
import com.hippo.ehviewer.ui.main.GalleryCommentCard
import com.hippo.ehviewer.ui.main.GalleryDetailErrorTip
import com.hippo.ehviewer.ui.main.GalleryDetailHeaderCard
import com.hippo.ehviewer.ui.main.GalleryTags
@@ -161,8 +161,8 @@ import com.hippo.ehviewer.util.ExceptionUtils
import com.hippo.ehviewer.util.FavouriteStatusRouter
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.IntList
import com.hippo.ehviewer.util.ReadableTime
import com.hippo.ehviewer.util.addTextToClipboard
import com.hippo.ehviewer.util.findActivity
import com.hippo.ehviewer.util.getParcelableCompat
import com.hippo.ehviewer.util.isAtLeastQ
import com.hippo.ehviewer.util.requestPermission
@@ -330,7 +330,6 @@ class GalleryDetailScene : BaseScene() {
lifecycleScope.launchIO {
dialogState.awaitPermissionOrCancel(
confirmText = R.string.clear_all,
dismissText = android.R.string.cancel,
title = R.string.clear_image_cache,
) {
Text(text = stringResource(id = R.string.clear_image_cache_confirm))
@@ -974,28 +973,15 @@ class GalleryDetailScene : BaseScene() {
private fun onNavigateToCommentScene() {
val galleryDetail = composeBindingGD ?: return
val args = Bundle()
args.putLong(GalleryCommentsScene.KEY_API_UID, galleryDetail.apiUid)
args.putString(
GalleryCommentsScene.KEY_API_KEY,
galleryDetail.apiKey,
navAnimated(
R.id.galleryCommentsScene,
bundleOf(GalleryCommentsFragment.KEY_GALLERY_DETAIL to galleryDetail),
)
args.putLong(GalleryCommentsScene.KEY_GID, galleryDetail.gid)
args.putString(GalleryCommentsScene.KEY_TOKEN, galleryDetail.token)
args.putParcelable(
GalleryCommentsScene.KEY_COMMENT_LIST,
galleryDetail.comments,
)
args.putParcelable(
GalleryCommentsScene.KEY_GALLERY_DETAIL,
galleryDetail,
)
navAnimated(R.id.galleryCommentsScene, args)
}
@SuppressLint("InflateParams")
@Composable
private fun GalleryDetailComment(commentsList: Array<GalleryComment>) {
private fun GalleryDetailComment(commentsList: List<GalleryComment>) {
val maxShowCount = 2
val commentText = if (commentsList.isEmpty()) {
stringResource(R.string.no_comments)
@@ -1008,13 +994,27 @@ class GalleryDetailScene : BaseScene() {
val length = maxShowCount.coerceAtMost(commentsList.size)
for (i in 0 until length) {
val item = commentsList[i]
AndroidViewBinding(factory = ItemGalleryCommentBinding::inflate) {
card.setOnClickListener { onNavigateToCommentScene() }
user.text = item.user
user.setBackgroundColor(Color.TRANSPARENT)
time.text = ReadableTime.getTimeAgo(item.time)
comment.maxLines = 5
comment.text = item.comment.orEmpty().parseAsHtml(imageGetter = CoilImageGetter(comment))
GalleryCommentCard(
modifier = Modifier.padding(vertical = 4.dp),
comment = item,
onCardClick = {
onNavigateToCommentScene()
},
onUserClick = {
onNavigateToCommentScene()
},
onUrlClick = {
val activity = context?.findActivity<MainActivity>() ?: return@GalleryCommentCard
val galleryDetail = composeBindingGD ?: return@GalleryCommentCard
if (!activity.jumpToReaderByPage(it, galleryDetail)) {
if (!findNavController().navWithUrl(it)) {
activity.openBrowser(it)
}
}
},
) {
maxLines = 5
text = item.comment.orEmpty().parseAsHtml(imageGetter = CoilImageGetter(this))
}
}
Box(
@@ -388,11 +388,7 @@ class GalleryListScene : SearchBarScene() {
binding.tip.text = ExceptionUtils.getReadableString(state.error)
transition.showView(2)
if (state.error.cause is CloudflareBypassException) {
dialogState.awaitPermissionOrCancel(
android.R.string.ok,
android.R.string.cancel,
R.string.cloudflare_bypass_failed,
) {
dialogState.awaitPermissionOrCancel(title = R.string.cloudflare_bypass_failed) {
Text(text = stringResource(id = R.string.open_in_webview))
}
navAnimated(R.id.webView, bundleOf(WebViewActivity.KEY_URL to EhUrl.host))
@@ -108,7 +108,6 @@ fun HistoryScreen(navigator: NavController) {
coroutineScope.launchIO {
dialogState.awaitPermissionOrCancel(
confirmText = R.string.clear_all,
dismissText = android.R.string.cancel,
text = { Text(text = stringResource(id = R.string.clear_all_history)) },
)
EhDB.clearHistoryInfo()
@@ -133,7 +133,6 @@ fun AboutScreen(navigator: DestinationsNavigator) {
suspend fun DialogState.showNewVersion(context: Context, release: Release) {
awaitPermissionOrCancel(
confirmText = R.string.download,
dismissText = android.R.string.cancel,
title = R.string.new_version_available,
) {
Column(Modifier.verticalScroll(rememberScrollState())) {
@@ -199,17 +199,17 @@ fun EhScreen(navigator: DestinationsNavigator) {
in 0..9 -> Settings.favCat[defaultFavSlot]
else -> stringResource(id = R.string.default_favorites_warning)
}
val items = if (signin) {
arrayOf(disabled, localFav, *Settings.favCat)
} else {
arrayOf(disabled, localFav)
}
Preference(
title = stringResource(id = R.string.default_favorites_collection),
summary = summary,
) {
coroutineScope.launch {
defaultFavSlot = dialogState.showSelectItem(*items, title = R.string.default_favorites_collection) - 2
defaultFavSlot = dialogState.showSelectItem(
disabled,
localFav,
*Settings.favCat.takeIf { signin } ?: emptyArray(),
title = R.string.default_favorites_collection,
) - 2
}
}
SimpleMenuPreferenceInt(
@@ -169,10 +169,7 @@ fun FilterScreen(navigator: DestinationsNavigator) {
IconButton(
onClick = {
scope.launch {
dialogState.awaitPermissionOrCancel(
confirmText = R.string.delete,
dismissText = android.R.string.cancel,
) {
dialogState.awaitPermissionOrCancel(confirmText = R.string.delete) {
Text(text = stringResource(id = R.string.delete_filter, filter.text))
}
filter.forget {
@@ -81,7 +81,6 @@ fun PrivacyScreen(navigator: DestinationsNavigator) {
coroutineScope.launch {
dialogState.awaitPermissionOrCancel(
confirmText = R.string.clear_all,
dismissText = android.R.string.cancel,
title = R.string.clear_search_history_confirm,
)
searchDatabase.searchDao().clear()
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
@@ -49,6 +50,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import com.jamal.composeprefs3.ui.ifNotNullThen
import com.jamal.composeprefs3.ui.ifTrueThen
import kotlin.coroutines.resume
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.launch
@@ -157,8 +159,9 @@ class DialogState {
}
suspend fun awaitPermissionOrCancel(
@StringRes confirmText: Int? = null,
@StringRes dismissText: Int? = null,
@StringRes confirmText: Int = android.R.string.ok,
@StringRes dismissText: Int = android.R.string.cancel,
showCancelButton: Boolean = true,
@StringRes title: Int? = null,
onDismiss: () -> Unit = {},
text: (@Composable () -> Unit)? = null,
@@ -171,15 +174,15 @@ class DialogState {
},
confirmButton = {
TextButton(onClick = { cont.resume(Unit) }) {
Text(text = stringResource(id = confirmText ?: android.R.string.ok))
Text(text = stringResource(id = confirmText))
}
},
dismissButton = dismissText.ifNotNullThen {
dismissButton = showCancelButton.ifTrueThen {
TextButton(onClick = {
onDismiss()
cont.cancel()
}) {
Text(text = stringResource(id = dismissText!!))
Text(text = stringResource(id = dismissText))
}
},
title = title.ifNotNullThen { Text(text = stringResource(id = title!!)) },
@@ -188,7 +191,7 @@ class DialogState {
}
}
private suspend fun <R> showNoButton(respectDefaultWidth: Boolean = true, block: @Composable DismissDialogScope<R>.() -> Unit): R {
suspend fun <R> showNoButton(respectDefaultWidth: Boolean = true, block: @Composable DismissDialogScope<R>.() -> Unit): R {
return dialog { cont ->
val impl = remember(cont) {
DismissDialogScope<R> {
@@ -212,21 +215,31 @@ class DialogState {
}
suspend fun showSelectItem(
vararg items: String,
vararg items: String?,
@StringRes title: Int,
): Int = showNoButton {
) = showSelectItem(
*items.filterNotNull().mapIndexed { a, b -> b to a }.toTypedArray(),
title = title,
)
suspend fun <R> showSelectItem(
vararg items: Pair<String, R>,
@StringRes title: Int? = null,
): R = showNoButton {
Column(modifier = Modifier.padding(vertical = 8.dp)) {
Text(
text = stringResource(id = title),
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
style = MaterialTheme.typography.headlineSmall,
)
title.ifNotNullThen {
Text(
text = stringResource(id = title!!),
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
style = MaterialTheme.typography.headlineSmall,
)
}
LazyColumn {
itemsIndexed(items) { index, text ->
items(items) { (text, item) ->
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.tertiary) {
Text(
text = text,
modifier = Modifier.clickable { dismissWith(index) }.fillMaxWidth()
modifier = Modifier.clickable { dismissWith(item) }.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
style = MaterialTheme.typography.titleMedium,
)
+1 -1
View File
@@ -49,7 +49,7 @@
android:label="GalleryDetailScene" />
<fragment
android:id="@+id/galleryCommentsScene"
android:name="com.hippo.ehviewer.ui.scene.GalleryCommentsScene"
android:name="com.hippo.ehviewer.ui.scene.GalleryCommentsFragment"
android:label="GalleryCommentsScene" />
<fragment
android:id="@+id/galleryPreviewsScene"