From 3d9e16bb280e446289ae572dce9964df1770d0b2 Mon Sep 17 00:00:00 2001 From: zyraxi Date: Fri, 5 Jun 2026 20:32:35 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20legal=20info=20page=20=E2=80=94=20full-s?= =?UTF-8?q?creen=20dialog,=20swipeable=20tabs,=20pull-to-dismiss?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重写 UserAgreementDialog 以修复协议页面无法滚动、无法滑动切 标签、下拉关闭行为异常等一系列问题。 # 核心改动 UserAgreementDialog: - BottomSheetDialogFragment → DialogFragment(全屏 + 底部滑入动画) - NestedScrollView → Compose verticalScroll(解决滚动冲突) - 新增 HorizontalPager + PrimaryTabRow 联动(左右滑动切换标签) - 新增 initialPage 参数("指哪打哪":点《用户协议》跳用户协议, 点《隐私政策》跳隐私政策) - 新增 pull-to-dismiss(文字到顶后下拉关闭,含弹性回弹动画, 惯性快速下划立即关闭,回弹中触屏可打断) - TabRow → PrimaryTabRow(消除弃用 warning) - Html.fromHtml(String) → fromHtml(String, FROM_HTML_MODE_LEGACY) - 文字背景改为透明 MainActivity: - 导航栏跟随抽屉动画同步平移和缩放 PopUpLoginEAS: - createAgreementSpannable 按点击的链接传入正确的 initialPage Strings: - "隐私协议" → "隐私政策" - "用户与隐私协议" → "用户协议与隐私政策" Res: - 新增 anim/slide_in_bottom.xml, anim/slide_out_bottom.xml - themes.xml 新增 DialogSlideAnimation 样式 --- .../hita/ui/about/UserAgreementDialog.kt | 312 +++++++++++++++--- .../limpu/hita/ui/eas/login/PopUpLoginEAS.kt | 11 +- .../cn/limpu/hita/ui/main/MainActivity.kt | 6 + app/src/main/res/anim/slide_in_bottom.xml | 6 + app/src/main/res/anim/slide_out_bottom.xml | 6 + app/src/main/res/values-night/themes.xml | 6 + app/src/main/res/values/strings.xml | 6 +- app/src/main/res/values/themes.xml | 5 + 8 files changed, 296 insertions(+), 62 deletions(-) create mode 100644 app/src/main/res/anim/slide_in_bottom.xml create mode 100644 app/src/main/res/anim/slide_out_bottom.xml diff --git a/app/src/main/java/cn/limpu/hita/ui/about/UserAgreementDialog.kt b/app/src/main/java/cn/limpu/hita/ui/about/UserAgreementDialog.kt index 9dbd5a9b..1be83c2a 100644 --- a/app/src/main/java/cn/limpu/hita/ui/about/UserAgreementDialog.kt +++ b/app/src/main/java/cn/limpu/hita/ui/about/UserAgreementDialog.kt @@ -2,49 +2,75 @@ package cn.limpu.hita.ui.about import android.os.Bundle import android.text.Html -import androidx.core.widget.NestedScrollView +import android.text.method.LinkMovementMethod import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.WindowManager import android.widget.TextView import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow -import androidx.compose.material3.TabRowDefaults -import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.spring import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView -import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import androidx.fragment.app.DialogFragment import cn.limpu.hita.R import cn.limpu.hita.ui.design.HitaComposeTheme import cn.limpu.hita.ui.design.HitaTheme +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.launch -@Suppress("DEPRECATION") -class UserAgreementDialog : BottomSheetDialogFragment() { +class UserAgreementDialog : DialogFragment() { var onResponseListener: OnResponseListener? = null private var showActionButtons = false + var initialPage: Int = 0 interface OnResponseListener { fun onAgree() @@ -56,6 +82,12 @@ class UserAgreementDialog : BottomSheetDialogFragment() { return this } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NO_FRAME, R.style.AppTheme) + isCancelable = onResponseListener == null + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -63,9 +95,10 @@ class UserAgreementDialog : BottomSheetDialogFragment() { ): View { return ComposeView(requireContext()).apply { setContent { - HitaComposeTheme() { + HitaComposeTheme { UserAgreementScreen( showActions = showActionButtons || onResponseListener != null, + initialPage = initialPage, onAgree = { onResponseListener?.onAgree() dismiss() @@ -73,23 +106,33 @@ class UserAgreementDialog : BottomSheetDialogFragment() { onRefuse = { onResponseListener?.onRefuse() dismiss() - } + }, + onDismiss = { dismiss() } ) } } } } - override fun isCancelable(): Boolean { - return onResponseListener == null + override fun onStart() { + super.onStart() + dialog?.window?.apply { + setLayout( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.MATCH_PARENT + ) + setWindowAnimations(R.style.DialogSlideAnimation) + } } } @Composable private fun UserAgreementScreen( showActions: Boolean, + initialPage: Int = 0, onAgree: () -> Unit, - onRefuse: () -> Unit + onRefuse: () -> Unit, + onDismiss: () -> Unit ) { val tokens = HitaTheme.tokens val tabs = listOf( @@ -98,35 +141,175 @@ private fun UserAgreementScreen( ) val uaContent = stringResource(R.string.user_agreement) val ppContent = stringResource(R.string.privacy_policy) - var selectedTab by remember { mutableIntStateOf(0) } + val pages = listOf(uaContent, ppContent) - val screenHeightDp = LocalConfiguration.current.screenHeightDp.dp - Column(modifier = Modifier - .fillMaxWidth() - .heightIn(max = screenHeightDp * 0.8f) - .background(MaterialTheme.colorScheme.surface) - ) { - TabRow( - selectedTabIndex = selectedTab, - containerColor = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.primary, - indicator = { tabPositions -> - if (selectedTab < tabPositions.size) { - TabRowDefaults.SecondaryIndicator( - modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTab]), - color = MaterialTheme.colorScheme.primary - ) + val pagerState = rememberPagerState( + initialPage = initialPage.coerceIn(0, pages.size - 1), + pageCount = { pages.size } + ) + val scope = rememberCoroutineScope() + + // --- Pull-to-dismiss when scrolled to top --- + val density = LocalDensity.current + val dismissThresholdPx = with(density) { 120.dp.toPx() } + var dismissOffset by remember { mutableFloatStateOf(0f) } + var snapBackJob by remember { mutableStateOf(null) } + val handler = remember { android.os.Handler(android.os.Looper.getMainLooper()) } + val onDismissUpdated = rememberUpdatedState(onDismiss) + + // Track whether any pointer is currently down, to distinguish + // "finger held still" (no scroll events but finger down) from + // "finger lifted without velocity" (no scroll events and no finger) + var pointerCount by remember { mutableStateOf(0) } + val touchModifier = Modifier.pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + pointerCount = event.changes.count { it.pressed } + } + } + } + + // Non-fling release: debounce detects when scroll events stop, then decide + LaunchedEffect(Unit) { + snapshotFlow { dismissOffset } + .debounce(200) + .collect { offset -> + if (pointerCount > 0) return@collect // finger still down + if (offset >= dismissThresholdPx) { + handler.post { + dismissOffset = 0f + onDismissUpdated.value() + } + } else if (offset > 0f) { + snapBackJob?.cancel() + snapBackJob = scope.launch { + animate( + offset, 0f, + animationSpec = spring(dampingRatio = 0.8f, stiffness = 300f) + ) { value, _ -> dismissOffset = value } + } + } + } + } + + val dismissConnection = remember(dismissThresholdPx) { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + snapBackJob?.cancel() + snapBackJob = null + if (dismissOffset > 0f) { + dismissOffset = + (dismissOffset + available.y).coerceAtLeast(0f) + return available } + return Offset.Zero } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + if (source == NestedScrollSource.UserInput) { + snapBackJob?.cancel() + snapBackJob = null + val dY = available.y + if (dY > 0f) { + dismissOffset = + (dismissOffset + dY * 0.4f).coerceAtLeast(0f) + return Offset(0f, dY) + } else if (dY < 0f && dismissOffset > 0f) { + dismissOffset = + (dismissOffset + dY).coerceAtLeast(0f) + return Offset(0f, dY) + } + } + return Offset.Zero + } + + // Intercept high-velocity fling BEFORE it runs — dismiss immediately + override suspend fun onPreFling(available: Velocity): Velocity { + if (dismissOffset > 0f && available.y > dismissThresholdPx) { + handler.post { + dismissOffset = 0f + onDismissUpdated.value() + } + return available + } + return Velocity.Zero + } + + // After fling settles: handle threshold-based dismiss or snap-back + override suspend fun onPostFling( + consumed: Velocity, + available: Velocity + ): Velocity { + if (dismissOffset > 0f) { + if (dismissOffset >= dismissThresholdPx) { + handler.post { + dismissOffset = 0f + onDismissUpdated.value() + } + } else { + snapBackJob?.cancel() + snapBackJob = scope.launch { + animate( + dismissOffset, 0f, + animationSpec = spring(dampingRatio = 0.8f, stiffness = 300f) + ) { value, _ -> dismissOffset = value } + } + } + } + return Velocity.Zero + } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .statusBarsPadding() + .then(touchModifier) + .nestedScroll(dismissConnection) + .graphicsLayer { translationY = dismissOffset } + ) { + // Top bar: close button + Row( + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + IconButton(onClick = onDismiss) { + Icon( + painter = painterResource(R.drawable.ic_baseline_close_24), + contentDescription = stringResource(R.string.cancel), + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + + // Tab row — synced with HorizontalPager + PrimaryTabRow( + selectedTabIndex = pagerState.currentPage, + containerColor = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.primary ) { tabs.forEachIndexed { index, title -> Tab( - selected = selectedTab == index, - onClick = { selectedTab = index }, + selected = pagerState.currentPage == index, + onClick = { + scope.launch { + pagerState.animateScrollToPage(index) + } + }, text = { Text( text = title, - color = if (selectedTab == index) { + fontSize = 16.sp, + color = if (pagerState.currentPage == index) { MaterialTheme.colorScheme.primary } else { MaterialTheme.colorScheme.onSurfaceVariant @@ -137,36 +320,53 @@ private fun UserAgreementScreen( } } + // Pager content — each page is independently scrollable val textColor = MaterialTheme.colorScheme.onSurface.toArgb() val linkColor = MaterialTheme.colorScheme.primary.toArgb() - val surfaceColor = MaterialTheme.colorScheme.surface.toArgb() - AndroidView( + + HorizontalPager( + state = pagerState, modifier = Modifier .fillMaxWidth() .weight(1f) - .padding(tokens.spacing.sm), - factory = { context -> - NestedScrollView(context).apply { - isFillViewport = true - addView(TextView(context).apply { - setPadding(16, 8, 16, 8) - }) - } - }, - update = { nestedScrollView -> - val textView = nestedScrollView.getChildAt(0) as TextView - val newContent = if (selectedTab == 0) uaContent else ppContent - textView.text = Html.fromHtml(newContent) - textView.setTextColor(textColor) - textView.setLinkTextColor(linkColor) - textView.setBackgroundColor(surfaceColor) + ) { pageIndex -> + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding( + start = tokens.spacing.md, + top = tokens.spacing.sm, + end = tokens.spacing.md, + bottom = tokens.spacing.lg + ) + ) { + AndroidView( + modifier = Modifier.fillMaxWidth(), + factory = { context -> + TextView(context).apply { + movementMethod = LinkMovementMethod.getInstance() + textSize = 15f + } + }, + update = { textView -> + val newContent = pages[pageIndex] + if (textView.text.toString() != newContent) { + textView.text = + Html.fromHtml(newContent, Html.FROM_HTML_MODE_LEGACY) + textView.setTextColor(textColor) + textView.setLinkTextColor(linkColor) + } + } + ) } - ) + } if (showActions) { Row( modifier = Modifier .fillMaxWidth() + .navigationBarsPadding() .padding( start = tokens.spacing.sm, end = tokens.spacing.sm, @@ -199,5 +399,9 @@ private fun UserAgreementScreen( } } } + + if (!showActions) { + Spacer(Modifier.navigationBarsPadding()) + } } } diff --git a/app/src/main/java/cn/limpu/hita/ui/eas/login/PopUpLoginEAS.kt b/app/src/main/java/cn/limpu/hita/ui/eas/login/PopUpLoginEAS.kt index f9475ee9..20bf7ae4 100644 --- a/app/src/main/java/cn/limpu/hita/ui/eas/login/PopUpLoginEAS.kt +++ b/app/src/main/java/cn/limpu/hita/ui/eas/login/PopUpLoginEAS.kt @@ -139,9 +139,10 @@ class PopUpLoginEAS : BottomSheetDialogFragment() { onFailed = { onResponseListener?.onFailed(this@PopUpLoginEAS) }, - onShowAgreement = { + onShowAgreement = { pageIndex -> UserAgreementDialog().apply { setShowActionButtons(false) + initialPage = pageIndex }.show(childFragmentManager, "user_agreement_view") } ) @@ -270,7 +271,7 @@ private fun LoginEASScreen( onAutoLaunch: (EASToken.Campus) -> Unit, onSuccess: () -> Unit, onFailed: () -> Unit, - onShowAgreement: () -> Unit + onShowAgreement: (Int) -> Unit ) { val tokens = HitaTheme.tokens val view = LocalView.current @@ -491,7 +492,7 @@ private fun LoginEASScreen( private fun createAgreementSpannable( context: Context, - onShowAgreement: () -> Unit + onShowAgreement: (Int) -> Unit ): android.text.SpannableString { val hint = context.getString(R.string.user_agreement_hint) val span = android.text.SpannableString(hint) @@ -503,14 +504,14 @@ private fun createAgreementSpannable( if (uaStart >= 0 && uaEnd > uaStart) { span.setSpan(object : android.text.style.ClickableSpan() { override fun onClick(widget: View) { - onShowAgreement() + onShowAgreement(0) } }, uaStart, uaEnd, android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) } if (ppStart >= 0 && ppEnd > ppStart) { span.setSpan(object : android.text.style.ClickableSpan() { override fun onClick(widget: View) { - onShowAgreement() + onShowAgreement(1) } }, ppStart, ppEnd, android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) } diff --git a/app/src/main/java/cn/limpu/hita/ui/main/MainActivity.kt b/app/src/main/java/cn/limpu/hita/ui/main/MainActivity.kt index e8f01995..d3e69a6f 100644 --- a/app/src/main/java/cn/limpu/hita/ui/main/MainActivity.kt +++ b/app/src/main/java/cn/limpu/hita/ui/main/MainActivity.kt @@ -815,6 +815,12 @@ private fun MainScreen( modifier = Modifier .align(Alignment.BottomCenter) .padding(bottom = systemNavAvoidance) + .graphicsLayer { + translationX = contentOffset + scaleX = contentScale + scaleY = contentScale + transformOrigin = androidx.compose.ui.graphics.TransformOrigin(1f, 0.5f) + } ) } diff --git a/app/src/main/res/anim/slide_in_bottom.xml b/app/src/main/res/anim/slide_in_bottom.xml new file mode 100644 index 00000000..80beee68 --- /dev/null +++ b/app/src/main/res/anim/slide_in_bottom.xml @@ -0,0 +1,6 @@ + + diff --git a/app/src/main/res/anim/slide_out_bottom.xml b/app/src/main/res/anim/slide_out_bottom.xml new file mode 100644 index 00000000..275e1346 --- /dev/null +++ b/app/src/main/res/anim/slide_out_bottom.xml @@ -0,0 +1,6 @@ + + diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index 0530214e..6bf5008f 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -160,6 +160,12 @@ 旧主题别名 (兼容性保留) ========================================== --> +