diff --git a/app/src/main/kotlin/com/hippo/ehviewer/client/data/GalleryCommentList.kt b/app/src/main/kotlin/com/hippo/ehviewer/client/data/GalleryCommentList.kt index de41da250..a0901f751 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/client/data/GalleryCommentList.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/client/data/GalleryCommentList.kt @@ -19,7 +19,7 @@ import android.os.Parcelable import kotlinx.parcelize.Parcelize @Parcelize -class GalleryCommentList( - var comments: Array, +data class GalleryCommentList( + val comments: List, val hasMore: Boolean, ) : Parcelable diff --git a/app/src/main/kotlin/com/hippo/ehviewer/client/parser/GalleryDetailParser.kt b/app/src/main/kotlin/com/hippo/ehviewer/client/parser/GalleryDetailParser.kt index 491ef4ea0..b837bac65 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/client/parser/GalleryDetailParser.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/client/parser/GalleryDetailParser.kt @@ -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() - 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() diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/CommonOperations.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/CommonOperations.kt index d3947f031..90506d803 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/CommonOperations.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/CommonOperations.kt @@ -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 = { diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/legacy/LinkifyTextView.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/legacy/LinkifyTextView.kt index 99e12ad01..0a8d95806 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/legacy/LinkifyTextView.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/legacy/LinkifyTextView.kt @@ -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]) } } } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/login/CookieSignInScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/login/CookieSignInScreen.kt index 36536a64f..be1dcc996 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/login/CookieSignInScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/login/CookieSignInScreen.kt @@ -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( diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/login/SignInScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/login/SignInScreen.kt index 23bd3b2df..e420b8237 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/login/SignInScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/login/SignInScreen.kt @@ -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( diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/main/GalleryComment.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/main/GalleryComment.kt new file mode 100644 index 000000000..2240e6347 --- /dev/null +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/main/GalleryComment.kt @@ -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(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, + ) + } + } +} diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/FavoritesScene.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/FavoritesScene.kt index 7bd8ddca4..7a6c03f79 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/FavoritesScene.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/FavoritesScene.kt @@ -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) }, ) { diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryCommentsFragment.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryCommentsFragment.kt new file mode 100644 index 000000000..acb887896 --- /dev/null +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryCommentsFragment.kt @@ -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().showTip( + if (false) editCommentSuccess else commentSuccess, + BaseScene.LENGTH_SHORT, + ) + userComment = "" + comments = it + }.onFailure { + val text = if (false) editCommentFail else commentFail + findActivity().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().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 { + 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().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().showTip(R.string.vote_failed, BaseScene.LENGTH_LONG) + } + } + + suspend fun Context.doCommentAction(comment: GalleryComment) { + val actions = buildAction { + copyComment thenDo { findActivity().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() + 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(KEY_GALLERY_DETAIL)!! } + val navController = remember { findNavController() } + GalleryCommentsScreen(galleryDetail = galleryDetail, navigator = navController) + } + } + + companion object { + const val KEY_GALLERY_DETAIL = "gallery_detail" + } +} diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryCommentsScene.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryCommentsScene.kt deleted file mode 100644 index 9f517f495..000000000 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryCommentsScene.kt +++ /dev/null @@ -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(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(start, end) else getSpans(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( - 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(length) - val voteArray = arrayOfNulls(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() { - 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 = 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() { - 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 - } -} diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryDetailScene.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryDetailScene.kt index ae5a3414a..326ca4876 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryDetailScene.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryDetailScene.kt @@ -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) { + private fun GalleryDetailComment(commentsList: List) { 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() ?: 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( diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryListScene.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryListScene.kt index bf88223f0..ddbe72884 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryListScene.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryListScene.kt @@ -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)) diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/HistoryFragment.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/HistoryFragment.kt index a8c7c57c5..a1ceeb853 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/HistoryFragment.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/HistoryFragment.kt @@ -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() diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/settings/AboutScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/settings/AboutScreen.kt index cbce740ae..95cb6e351 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/settings/AboutScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/settings/AboutScreen.kt @@ -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())) { diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/settings/EhScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/settings/EhScreen.kt index 08ef3a8cd..e45bee2e9 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/settings/EhScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/settings/EhScreen.kt @@ -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( diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/settings/FilterScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/settings/FilterScreen.kt index 84fd4f98a..437a9f4c0 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/settings/FilterScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/settings/FilterScreen.kt @@ -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 { diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/settings/PrivacyScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/settings/PrivacyScreen.kt index 169543423..2bcd59f71 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/settings/PrivacyScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/settings/PrivacyScreen.kt @@ -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() diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/Dialog.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/Dialog.kt index 5039df7c9..fe990e7c2 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/Dialog.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/Dialog.kt @@ -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 showNoButton(respectDefaultWidth: Boolean = true, block: @Composable DismissDialogScope.() -> Unit): R { + suspend fun showNoButton(respectDefaultWidth: Boolean = true, block: @Composable DismissDialogScope.() -> Unit): R { return dialog { cont -> val impl = remember(cont) { DismissDialogScope { @@ -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 showSelectItem( + vararg items: Pair, + @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, ) diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index b825feb05..48ad7573a 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -49,7 +49,7 @@ android:label="GalleryDetailScene" />