From 52801c3b486fdf2d6de2698e57f8210d5d2e7258 Mon Sep 17 00:00:00 2001 From: YaoJunchang Date: Thu, 12 Mar 2026 14:27:15 +0800 Subject: [PATCH 01/31] =?UTF-8?q?=F0=9F=8E=A8=20add=20loading=20indicators?= =?UTF-8?q?=20in=20ImageViewer=20for=20better=20user=20feedback=20during?= =?UTF-8?q?=20image=20loading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/bookmark/components/ImageViewer.kt | 24 +++++++++++++++++++ .../ui/bookmark/components/PageIndicator.kt | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/ImageViewer.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/ImageViewer.kt index 3a9654dc..2332e837 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/ImageViewer.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/ImageViewer.kt @@ -24,7 +24,9 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.unit.IntSize +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.ui.unit.dp +import com.github.panpf.sketch.request.LoadState import com.github.panpf.sketch.AsyncImage import com.github.panpf.sketch.rememberAsyncImageState import com.slax.reader.const.component.rememberDismissableVisibility @@ -415,5 +417,27 @@ private fun ZoomableImagePage( translationY = if (isDoubleTapAnimating) animatedOffsetY else offsetY } ) + + // 图片加载中时显示进度圈 + if (asyncImageState.loadState is LoadState.Started) { + val progress = asyncImageState.progress?.decimalProgress + if (progress != null && progress > 0f) { + // 确定性进度圈(显示具体进度) + CircularProgressIndicator( + progress = { progress }, + modifier = Modifier.align(Alignment.Center), + color = Color.White, + trackColor = Color.White.copy(alpha = 0.3f), + strokeWidth = 3.dp + ) + } else { + // 不确定性进度圈(无法获取进度时) + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = Color.White, + strokeWidth = 3.dp + ) + } + } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/PageIndicator.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/PageIndicator.kt index b303d49d..368acf4d 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/PageIndicator.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/PageIndicator.kt @@ -37,7 +37,7 @@ fun PageIndicator( .width(if (isActive) 12.dp else 6.dp) .height(6.dp) .clip(RoundedCornerShape(3.dp)) - .background(if (isActive) Color(0xFF333333) else Color(0x33333333)) + .background(if (isActive) Color.White else Color(0x66FFFFFF)) ) } } From b53991e3698a8ffd642f44cccac37b83baa09952 Mon Sep 17 00:00:00 2001 From: YaoJunchang Date: Thu, 12 Mar 2026 14:32:45 +0800 Subject: [PATCH 02/31] =?UTF-8?q?=F0=9F=8E=A8=20add=20delete=20functionali?= =?UTF-8?q?ty=20to=20BookmarkDelegate=20and=20integrate=20with=20BottomToo?= =?UTF-8?q?lbarSheet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../slax/reader/ui/bookmark/DetailScreen.kt | 45 ++++++++++++++++++ .../com/slax/reader/ui/bookmark/ViewModel.kt | 9 ++++ .../bookmark/components/BottomToolbarSheet.kt | 1 + .../ui/bookmark/states/BookmarkDelegate.kt | 47 ++++++++++++++----- 4 files changed, 90 insertions(+), 12 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/DetailScreen.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/DetailScreen.kt index 3832cb2a..c0fa2055 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/DetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/DetailScreen.kt @@ -1,6 +1,19 @@ package com.slax.reader.ui.bookmark import androidx.compose.runtime.* +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import com.slax.reader.ui.bookmark.components.DetailScreenSkeleton import com.slax.reader.ui.bookmark.states.ScrollInfo import com.slax.reader.utils.* @@ -92,12 +105,44 @@ fun DetailScreen(bookmarkId: String, onEvent: (DetailScreenEvent) -> Unit) { } val contentState by viewModel.contentState.collectAsState() + val detailState by viewModel.bookmarkDelegate.bookmarkDetailState.collectAsState() if (contentState.htmlContent == null || contentState.isLoading) { DetailScreenSkeleton() return } + // 删除加载对话框 + if (detailState.isDeleting) { + Dialog( + onDismissRequest = { }, + properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false) + ) { + Box( + modifier = Modifier + .size(200.dp) + .background(Color.White, shape = RoundedCornerShape(8.dp)) + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + CircularProgressIndicator( + color = Color(0xFF16B998), + strokeWidth = 3.dp, + modifier = Modifier.size(48.dp) + ) + Text( + text = "删除中...", + style = TextStyle(fontSize = 16.sp) + ) + } + } + } + } + CompositionLocalProvider(LocalToolbarVisible provides toolbarVisible) { DetailScreen( htmlContent = contentState.htmlContent!!, diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt index efb017f0..848e5cde 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt @@ -178,6 +178,15 @@ class BookmarkDetailViewModel( } "feedback" -> overlayDelegate.showOverlay(BookmarkOverlay.FeedbackRequired) + "delete" -> bookmarkDelegate.onDeleteBookmark( + onSuccess = { + bookmarkEvent.action("delete").send() + requestNavigateBack() + }, + onFailure = { + bookmarkEvent.action("delete_failed").send() + } + ) } overlayDelegate.dismissOverlay(BookmarkOverlay.Toolbar) diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/BottomToolbarSheet.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/BottomToolbarSheet.kt index e8d6049c..26f35240 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/BottomToolbarSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/BottomToolbarSheet.kt @@ -60,6 +60,7 @@ fun BottomToolbarSheet() { // ToolbarIcon("comment", "评论", Res.drawable.ic_bottom_panel_comment), ToolbarIcon("edit_title", "detail_toolbar_edit_title".i18n(), Res.drawable.ic_bottom_panel_edittitle), ToolbarIcon("feedback", "detail_toolbar_feedback".i18n(), Res.drawable.ic_bottom_panel_feedback), + ToolbarIcon("delete", "删除", Res.drawable.ic_bottom_panel_delete), // ToolbarIcon("share", "分享", Res.drawable.ic_bottom_panel_share) ) // listOf( diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/BookmarkDelegate.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/BookmarkDelegate.kt index 650abc39..3b85d8a6 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/BookmarkDelegate.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/BookmarkDelegate.kt @@ -21,6 +21,7 @@ data class BookmarkDetailState( val displayTitle: String = "", val displayTime: String = "", val metadataUrl: String? = null, + val isDeleting: Boolean = false, ) class BookmarkDelegate( @@ -28,6 +29,8 @@ class BookmarkDelegate( private val bookmarkIdFlow: StateFlow, private val scope: CoroutineScope ) { + private val _isDeleting = MutableStateFlow(false) + @OptIn(ExperimentalCoroutinesApi::class) private val bookmarkFlow: StateFlow> = bookmarkIdFlow .filterNotNull() @@ -35,18 +38,21 @@ class BookmarkDelegate( .distinctUntilChanged() .stateIn(scope, SharingStarted.WhileSubscribed(5000), emptyList()) - val bookmarkDetailState: StateFlow = bookmarkFlow - .map { list -> - list.firstOrNull()?.let { b -> - BookmarkDetailState( - isStarred = b.isStarred == 1, - isArchived = b.archiveStatus == 1, - displayTitle = b.displayTitle, - displayTime = b.displayTime, - metadataUrl = b.metadataUrl, - ) - } ?: BookmarkDetailState() - } + val bookmarkDetailState: StateFlow = combine( + bookmarkFlow, + _isDeleting + ) { list, isDeleting -> + list.firstOrNull()?.let { b -> + BookmarkDetailState( + isStarred = b.isStarred == 1, + isArchived = b.archiveStatus == 1, + displayTitle = b.displayTitle, + displayTime = b.displayTime, + metadataUrl = b.metadataUrl, + isDeleting = isDeleting, + ) + } ?: BookmarkDetailState(isDeleting = isDeleting) + } .distinctUntilChanged() .stateIn(scope, SharingStarted.WhileSubscribed(5000), BookmarkDetailState()) @@ -98,6 +104,23 @@ class BookmarkDelegate( } } + fun onDeleteBookmark(onSuccess: () -> Unit, onFailure: () -> Unit) { + scope.launch { + _isDeleting.value = true + runCatching { + bookmarkIdFlow.value?.let { id -> + bookmarkDao.deleteBookmark(id) + } + }.onSuccess { + _isDeleting.value = false + onSuccess() + }.onFailure { + _isDeleting.value = false + onFailure() + } + } + } + suspend fun getTagNames(uuids: List): List = withContext(Dispatchers.IO) { return@withContext bookmarkDao.getTagsByIds(uuids) } From ea1fe2f75b7a4c5c5fa81747ba01137c6f841661 Mon Sep 17 00:00:00 2001 From: YaoJunchang Date: Thu, 12 Mar 2026 14:32:54 +0800 Subject: [PATCH 03/31] =?UTF-8?q?=F0=9F=8E=A8=20refactor=20layout=20in=20I?= =?UTF-8?q?nboxListScreen=20for=20improved=20readability=20and=20UI=20cons?= =?UTF-8?q?istency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../slax/reader/ui/inbox/InboxListScreen.kt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/InboxListScreen.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/InboxListScreen.kt index 29b0186e..f466e9b6 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/InboxListScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/InboxListScreen.kt @@ -149,20 +149,20 @@ private fun NavigationBar( ) { Row( modifier = Modifier - .align(Alignment.CenterStart), + .align(Alignment.CenterStart) + .alpha(if (isTabPressed) 0.5f else 1f) + .clickable( + interactionSource = tabInteractionSource, + indication = null + ) { + onAvatarClick() + }, horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Box( modifier = Modifier - .alpha(if (isTabPressed) 0.5f else 1f) - .clickable( - interactionSource = tabInteractionSource, - indication = null - ) { - onAvatarClick() - }, ) { Image( painter = painterResource(Res.drawable.ic_inbox_tab), From e94cc3c700a566c50e6923d78cfb544ce958a26a Mon Sep 17 00:00:00 2001 From: YaoJunchang Date: Fri, 13 Mar 2026 09:40:25 +0800 Subject: [PATCH 04/31] =?UTF-8?q?=F0=9F=8E=A8=20enhance=20image=20loading?= =?UTF-8?q?=20styles=20and=20improve=20error=20handling=20in=20webview=20b?= =?UTF-8?q?ridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/embedded/css/article.css | 2 ++ public/embedded/js/webview-bridge.js | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/public/embedded/css/article.css b/public/embedded/css/article.css index 6c235ee9..3ad4b328 100644 --- a/public/embedded/css/article.css +++ b/public/embedded/css/article.css @@ -71,6 +71,8 @@ img.slax-image-loading { background: linear-gradient(135deg, #f5f5f3 0%, rgba(153, 153, 153, 0.31) 100%); background-size: 400% 400%; animation: imageLoading 3s ease infinite; + width: 100%; + height: 100px; } img[onerror="this.style.display='none'"], diff --git a/public/embedded/js/webview-bridge.js b/public/embedded/js/webview-bridge.js index 4885a634..2fb0f103 100644 --- a/public/embedded/js/webview-bridge.js +++ b/public/embedded/js/webview-bridge.js @@ -1 +1 @@ -var SlaxReaderWebBridgeExports=function(e){"use strict";function t(){return window.NativeBridge?.postMessage?"android":window.webkit?.messageHandlers?.NativeBridge?"ios":"unknown"}function n(e){const n=JSON.stringify(e),r=t();return"android"===r?(window.NativeBridge.postMessage(n),!0):"ios"===r&&(window.webkit.messageHandlers.NativeBridge.postMessage(n),!0)}function r(){return Math.max(document.body.scrollHeight,document.body.offsetHeight,document.documentElement.clientHeight,document.documentElement.scrollHeight,document.documentElement.offsetHeight)}function o(e){const t=e.tagName.toLowerCase();if("img"===t){const t=e;return t.currentSrc||t.src||""}if("image"===t){const t=e;return t.href?.baseVal||e.getAttribute("href")||e.getAttribute("xlink:href")||""}return""}function i(){const e=document.querySelectorAll("img, image");e.forEach(t=>{t.style.cssText="",t.addEventListener("click",t=>{const r=Array.from(e).map(o).filter(e=>e&&(e.startsWith("https://")||e.startsWith("http://"))),i=o(t.currentTarget);n({type:"imageClick",src:i,allImages:r,index:r.indexOf(i)})})})}function a(e){let t=null,n=null;if(e instanceof HTMLElement||null===e?t=e:(t=e.element||null,n=e.range||null),t||n)try{const e=window.getSelection();!n&&t&&(n=document.createRange(),n.selectNodeContents(t)),e&&n&&(e.removeAllRanges(),e.addRange(n))}catch(e){}}function s(e){return e.split("").reverse().join("")}function c(e){return(e|-e)>>31&1}function d(e,t,n,r){let o=e.P[n],i=e.M[n];const a=r>>>31,s=t[n]|a,d=s|i,l=(s&o)+o^o|s;let u=i|~(l|o),f=o&l;const g=c(u&e.lastRowMask[n])-c(f&e.lastRowMask[n]);return u<<=1,f<<=1,f|=a,u|=c(r)-a,o=f|~(d|u),i=u&d,e.P[n]=o,e.M[n]=i,g}function l(e,t,n){if(0===t.length)return[];n=Math.min(n,t.length);const r=[],o=32,i=Math.ceil(t.length/o)-1,a={P:new Uint32Array(i+1),M:new Uint32Array(i+1),lastRowMask:new Uint32Array(i+1)};a.lastRowMask.fill(1<<31),a.lastRowMask[i]=1<<(t.length-1)%o;const s=new Uint32Array(i+1),c=new Map,l=[];for(let e=0;e<256;e++)l.push(s);for(let e=0;e=t.length)continue;t.charCodeAt(a)===n&&(r[e]|=1<0&&f[u]>=n+o;)u-=1;u===i&&f[u]<=n&&(f[u]{const o=Math.max(0,n.end-t.length-n.errors);return{start:l(s(e.slice(o,n.end)),r,n.errors).reduce((e,t)=>n.end-t.enda||!e.pointerBeforeReferenceNode}};else{if(!s(a))throw new TypeError(r);u={forward:function(e,t){return e.compareDocumentPosition(t)&o}(l,a)?function(){return!1}:function(){return l!==a},backward:function(){return l!==a||!e.pointerBeforeReferenceNode}}}var f;for(;u.forward();){if(null===(l=e.nextNode()))throw new RangeError(t);d+=l.nodeValue.length}e.nextNode()&&(l=e.previousNode());for(;u.backward();){if(null===(l=e.previousNode()))throw new RangeError(t);d-=l.nodeValue.length}if(!s(e.referenceNode))throw new RangeError(t);return d};var t="Iterator exhausted before seek ended.",n="Argument 1 of seek must use filter NodeFilter.SHOW_TEXT.",r="Argument 2 of seek must be an integer or a Text Node.",o=2,i=4,a=3;function s(e){return e.nodeType===a}}(w)),w}var E,v,y,N,M={};function S(){return E||(E=1,function(e){function t(e,t){if(!t&&e.firstChild)return e.firstChild;do{if(e.nextSibling)return e.nextSibling;e=e.parentNode}while(e);return e}Object.defineProperty(e,"__esModule",{value:!0}),e.default=function(e){var n="";return function(e,n){var r=function(e){if(e.startContainer.nodeType===Node.ELEMENT_NODE){return e.startContainer.childNodes[e.startOffset]||t(e.startContainer,!0)}return e.startContainer}(e),o=function(e){if(e.endContainer.nodeType===Node.ELEMENT_NODE){return e.endContainer.childNodes[e.endOffset]||t(e.endContainer,!0)}return t(e.endContainer)}(e);for(;r!==o;)n(r),r=t(r)}(e,function(t){if(t.nodeType===Node.TEXT_NODE){var r=t===e.startContainer?e.startOffset:0,o=t===e.endContainer?e.endOffset:t.textContent.length;n+=t.textContent.slice(r,o)}}),n}}(M)),M}function C(){if(v)return m;v=1,Object.defineProperty(m,"__esModule",{value:!0}),m.fromRange=function(e,n){if(void 0===e)throw new Error('missing required parameter "root"');if(void 0===n)throw new Error('missing required parameter "range"');var r=e.ownerDocument.createRange(),o=n.startContainer,i=n.startOffset;r.setStart(e,0),r.setEnd(o,i);var a=(0,t.default)(r).length,s=a+(0,t.default)(n).length;return{start:a,end:s}},m.toRange=function(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};if(void 0===t)throw new Error('missing required parameter "root"');var o=t.ownerDocument,i=o.createRange(),a=o.createNodeIterator(t,r),s=n.start||0,c=n.end||s,d=s-(0,e.default)(a,s),l=a.referenceNode,u=c-s+d,f=u-(0,e.default)(a,u),g=a.referenceNode;return i.setStart(l,d),i.setEnd(g,f),i};var e=n(h?g:(h=1,g=p().default)),t=n(S());function n(e){return e&&e.__esModule?e:{default:e}}var r=4;return m}var x=N?y:(N=1,y=C());function b(e){const t=e.trim().replace(/\s+/g," ");try{const e=function(e,t,n=!0){const r=e.trim().replace(/\s+/g," "),o=n?Math.max(3,Math.floor(r.length/3)):0,i={candidate:null};return function e(t){if(t.nodeType===Node.ELEMENT_NODE){const a=t;if(("undefined"==typeof navigator||!navigator.userAgent.includes("jsdom"))&&0===a.offsetHeight&&0===a.offsetWidth)return;const s=a.textContent,{text:c,ranges:d}=function(e){const t=[];return{text:e.replace(/(\s+)|([^\s])/g,(e,n,r,o)=>(t.push({start:o,end:o+e.length}),n?" ":e)),ranges:t}}(s||"");if(s&&s.length>=r.length-o){const e=u(c,r,o);if(e.length>0){e.sort((e,t)=>e.errors-t.errors);const t=e[0];if(!i.candidate||t.errorse(t))}}(document.body),i.candidate?{element:i.candidate.element,match:i.candidate.match}:null}(t);if(e){const{element:t,match:n}=e,r=x.toRange(t,{start:n.start,end:n.end});if(r){let e=r.commonAncestorContainer;return e.nodeType===Node.TEXT_NODE&&e.parentNode&&(e=e.parentNode),{element:e||t,range:r}}}}catch(e){}return null}function T(e){if(!e)return;const r=t();if("android"===r){const t=e.getBoundingClientRect(),r=window.pageYOffset||document.documentElement.scrollTop;n({type:"scrollToPosition",percentage:(t.top+r)/Math.max(document.body.scrollHeight,document.documentElement.scrollHeight)})}else"ios"===r&&e.scrollIntoView({behavior:"smooth",block:"center",inline:"nearest"})}function A(e){const t=b(decodeURIComponent(e));return!!t&&(T(t.element),a(t),!0)}function R(){const e=document.querySelector("body > .slax-reader-notfound-container > .slax-reader-notfound-btn-container");if(!e)return;const t=e.querySelector(".retry-btn"),r=e.querySelector(".feedback-btn");t&&t.addEventListener("click",()=>{n({type:"refreshContent"})}),r&&r.addEventListener("click",()=>{n({type:"feedback"})})}return e.SlaxWebViewBridge=class{constructor(){this.postMessage=n,this.getContentHeight=r,this.scrollToAnchor=A,this.highlightElement=a,this.findMatchingElement=b,this.scrollToElement=T,this.init()}init(){window.CSS&&window.CSS.escape||(window.CSS=window.CSS||{},window.CSS.escape=function(e){if(0===arguments.length)throw new TypeError("`CSS.escape` requires an argument.");for(var t,n=String(e),r=n.length,o=-1,i="";++o=48&&t<=57||t>=65&&t<=90||t>=97&&t<=122||95===t||45===t?n.charAt(o):"\\"+n.charAt(o):i+="�";return i}),"loading"===document.readyState?document.addEventListener("DOMContentLoaded",()=>{i(),R()}):(i(),R())}},e}({});window.SlaxWebViewBridge=new SlaxReaderWebBridgeExports.SlaxWebViewBridge; +var SlaxReaderWebBridgeExports=function(e){"use strict";function t(){return window.NativeBridge?.postMessage?"android":window.webkit?.messageHandlers?.NativeBridge?"ios":"unknown"}function n(e){const n=JSON.stringify(e),r=t();return"android"===r?(window.NativeBridge.postMessage(n),!0):"ios"===r&&(window.webkit.messageHandlers.NativeBridge.postMessage(n),!0)}function r(){return Math.max(document.body.scrollHeight,document.body.offsetHeight,document.documentElement.clientHeight,document.documentElement.scrollHeight,document.documentElement.offsetHeight)}function o(e){const t=e.tagName.toLowerCase();if("img"===t){const t=e;return t.currentSrc||t.src||""}if("image"===t){const t=e;return t.href?.baseVal||e.getAttribute("href")||e.getAttribute("xlink:href")||""}return""}function a(){const e=document.querySelectorAll("img, image");!function(e){const t="slax-image-loading";e.forEach(e=>{e.srcset="",e.onload=()=>{e.classList.remove(t),e.naturalWidth<5||e.naturalHeight<5?e.setAttribute("style","display: none;"):e.naturalWidth<200?e.setAttribute("style",`width: ${e.naturalWidth}px !important;`):["padding: 0 !important","height: auto !important;"].forEach(t=>{e.setAttribute("style",t)})},e.referrerPolicy="",e.onerror=()=>{e.classList.remove(t),e.style.display="none"},e.classList.add(t);const n=e.parentElement;(n?Array.from(n.childNodes):[]).every(e=>e.nodeType!==Node.ELEMENT_NODE||"img"===e.tagName.toLowerCase())&&(e.style.cssFloat="none")})}(Array.from(e).filter(e=>"img"===e.tagName.toLowerCase())),e.forEach(t=>{t.addEventListener("click",t=>{const r=["https://","http://","slaxstatics://","slaxstatic://"],a=Array.from(e).map(o).filter(e=>e&&r.some(t=>e.startsWith(t))),i=o(t.currentTarget);n({type:"imageClick",src:i,allImages:a,index:a.indexOf(i)})})})}function i(e){let t=null,n=null;if(e instanceof HTMLElement||null===e?t=e:(t=e.element||null,n=e.range||null),t||n)try{const e=window.getSelection();!n&&t&&(n=document.createRange(),n.selectNodeContents(t)),e&&n&&(e.removeAllRanges(),e.addRange(n))}catch(e){}}function s(e){return e.split("").reverse().join("")}function d(e){return(e|-e)>>31&1}function c(e,t,n,r){let o=e.P[n],a=e.M[n];const i=r>>>31,s=t[n]|i,c=s|a,l=(s&o)+o^o|s;let u=a|~(l|o),f=o&l;const h=d(u&e.lastRowMask[n])-d(f&e.lastRowMask[n]);return u<<=1,f<<=1,f|=i,u|=d(r)-i,o=f|~(c|u),a=u&c,e.P[n]=o,e.M[n]=a,h}function l(e,t,n){if(0===t.length)return[];n=Math.min(n,t.length);const r=[],o=32,a=Math.ceil(t.length/o)-1,i={P:new Uint32Array(a+1),M:new Uint32Array(a+1),lastRowMask:new Uint32Array(a+1)};i.lastRowMask.fill(1<<31),i.lastRowMask[a]=1<<(t.length-1)%o;const s=new Uint32Array(a+1),d=new Map,l=[];for(let e=0;e<256;e++)l.push(s);for(let e=0;e=t.length)continue;t.charCodeAt(i)===n&&(r[e]|=1<0&&f[u]>=n+o;)u-=1;u===a&&f[u]<=n&&(f[u]{const o=Math.max(0,n.end-t.length-n.errors);return{start:l(s(e.slice(o,n.end)),r,n.errors).reduce((e,t)=>n.end-t.endi||!e.pointerBeforeReferenceNode}};else{if(!s(i))throw new TypeError(r);u={forward:function(e,t){return e.compareDocumentPosition(t)&o}(l,i)?function(){return!1}:function(){return l!==i},backward:function(){return l!==i||!e.pointerBeforeReferenceNode}}}var f;for(;u.forward();){if(null===(l=e.nextNode()))throw new RangeError(t);c+=l.nodeValue.length}e.nextNode()&&(l=e.previousNode());for(;u.backward();){if(null===(l=e.previousNode()))throw new RangeError(t);c-=l.nodeValue.length}if(!s(e.referenceNode))throw new RangeError(t);return c};var t="Iterator exhausted before seek ended.",n="Argument 1 of seek must use filter NodeFilter.SHOW_TEXT.",r="Argument 2 of seek must be an integer or a Text Node.",o=2,a=4,i=3;function s(e){return e.nodeType===i}}(w)),w}var y,E,v,N,M={};function x(){return y||(y=1,function(e){function t(e,t){if(!t&&e.firstChild)return e.firstChild;do{if(e.nextSibling)return e.nextSibling;e=e.parentNode}while(e);return e}Object.defineProperty(e,"__esModule",{value:!0}),e.default=function(e){var n="";return function(e,n){var r=function(e){if(e.startContainer.nodeType===Node.ELEMENT_NODE){return e.startContainer.childNodes[e.startOffset]||t(e.startContainer,!0)}return e.startContainer}(e),o=function(e){if(e.endContainer.nodeType===Node.ELEMENT_NODE){return e.endContainer.childNodes[e.endOffset]||t(e.endContainer,!0)}return t(e.endContainer)}(e);for(;r!==o;)n(r),r=t(r)}(e,function(t){if(t.nodeType===Node.TEXT_NODE){var r=t===e.startContainer?e.startOffset:0,o=t===e.endContainer?e.endOffset:t.textContent.length;n+=t.textContent.slice(r,o)}}),n}}(M)),M}function C(){if(E)return m;E=1,Object.defineProperty(m,"__esModule",{value:!0}),m.fromRange=function(e,n){if(void 0===e)throw new Error('missing required parameter "root"');if(void 0===n)throw new Error('missing required parameter "range"');var r=e.ownerDocument.createRange(),o=n.startContainer,a=n.startOffset;r.setStart(e,0),r.setEnd(o,a);var i=(0,t.default)(r).length,s=i+(0,t.default)(n).length;return{start:i,end:s}},m.toRange=function(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};if(void 0===t)throw new Error('missing required parameter "root"');var o=t.ownerDocument,a=o.createRange(),i=o.createNodeIterator(t,r),s=n.start||0,d=n.end||s,c=s-(0,e.default)(i,s),l=i.referenceNode,u=d-s+c,f=u-(0,e.default)(i,u),h=i.referenceNode;return a.setStart(l,c),a.setEnd(h,f),a};var e=n(g?h:(g=1,h=p().default)),t=n(x());function n(e){return e&&e.__esModule?e:{default:e}}var r=4;return m}var b=N?v:(N=1,v=C());function S(e){const t=e.trim().replace(/\s+/g," ");try{const e=function(e,t,n=!0){const r=e.trim().replace(/\s+/g," "),o=n?Math.max(3,Math.floor(r.length/3)):0,a={candidate:null};return function e(t){if(t.nodeType===Node.ELEMENT_NODE){const i=t;if(("undefined"==typeof navigator||!navigator.userAgent.includes("jsdom"))&&0===i.offsetHeight&&0===i.offsetWidth)return;const s=i.textContent,{text:d,ranges:c}=function(e){const t=[];return{text:e.replace(/(\s+)|([^\s])/g,(e,n,r,o)=>(t.push({start:o,end:o+e.length}),n?" ":e)),ranges:t}}(s||"");if(s&&s.length>=r.length-o){const e=u(d,r,o);if(e.length>0){e.sort((e,t)=>e.errors-t.errors);const t=e[0];if(!a.candidate||t.errorse(t))}}(document.body),a.candidate?{element:a.candidate.element,match:a.candidate.match}:null}(t);if(e){const{element:t,match:n}=e,r=b.toRange(t,{start:n.start,end:n.end});if(r){let e=r.commonAncestorContainer;return e.nodeType===Node.TEXT_NODE&&e.parentNode&&(e=e.parentNode),{element:e||t,range:r}}}}catch(e){}return null}function A(e){if(!e)return;const r=t();if("android"===r){const t=e.getBoundingClientRect(),r=window.pageYOffset||document.documentElement.scrollTop;n({type:"scrollToPosition",percentage:(t.top+r)/Math.max(document.body.scrollHeight,document.documentElement.scrollHeight)})}else"ios"===r&&e.scrollIntoView({behavior:"smooth",block:"center",inline:"nearest"})}function R(e){const t=S(decodeURIComponent(e));return!!t&&(A(t.element),i(t),!0)}return e.SlaxWebViewBridge=class{constructor(){this.postMessage=n,this.getContentHeight=r,this.scrollToAnchor=R,this.highlightElement=i,this.findMatchingElement=S,this.scrollToElement=A,this.init()}init(){window.CSS&&window.CSS.escape||(window.CSS=window.CSS||{},window.CSS.escape=function(e){if(0===arguments.length)throw new TypeError("`CSS.escape` requires an argument.");for(var t,n=String(e),r=n.length,o=-1,a="";++o=48&&t<=57||t>=65&&t<=90||t>=97&&t<=122||95===t||45===t?n.charAt(o):"\\"+n.charAt(o):a+="�";return a}),"loading"===document.readyState?document.addEventListener("DOMContentLoaded",()=>{this.onDOMReady()}):this.onDOMReady()}onDOMReady(){a(),function(){const e=document.querySelector("body > .slax-reader-notfound-container > .slax-reader-notfound-btn-container");if(!e)return;const t=e.querySelector(".retry-btn"),r=e.querySelector(".feedback-btn");t&&t.addEventListener("click",()=>{n({type:"refreshContent"})}),r&&r.addEventListener("click",()=>{n({type:"feedback"})})}(),n({type:"domReady"})}},e}({});window.SlaxWebViewBridge=new SlaxReaderWebBridgeExports.SlaxWebViewBridge; From 4119a7469eaf288baab57ee58b8d820840e71810 Mon Sep 17 00:00:00 2001 From: YaoJunchang Date: Fri, 13 Mar 2026 10:09:04 +0800 Subject: [PATCH 05/31] =?UTF-8?q?=F0=9F=8E=A8=20add=20localization=20for?= =?UTF-8?q?=20minimize=20button=20and=20enhance=20layout=20in=20OutlineDia?= =?UTF-8?q?log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/slax/reader/const/LocaleString.kt | 4 + .../ui/bookmark/components/OutlineDialog.kt | 128 +++++++++++------- 2 files changed, 80 insertions(+), 52 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/const/LocaleString.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/const/LocaleString.kt index c66e9884..5074c50d 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/const/LocaleString.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/const/LocaleString.kt @@ -345,6 +345,10 @@ val localeString: Map> = mapOf( "zh" to "加载失败", "en" to "Load failed" ), + "outline_minimize" to mapOf( + "zh" to "最小化", + "en" to "Minimize" + ), // 通用按钮 "btn_cancel" to mapOf( diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt index 82043524..6397c2d5 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt @@ -156,61 +156,11 @@ private fun ExpandedOutlineDialog() { modifier = Modifier .fillMaxSize() .padding(horizontal = 0.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 0.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + .navigationBarsPadding(), ) { - val collapseInteractionSource = remember { MutableInteractionSource() } - val isCollapsePressed by collapseInteractionSource.collectIsPressedAsState() - - Box( - modifier = Modifier - .alpha(if (isCollapsePressed) 0.5f else 1f) - .clickable( - interactionSource = collapseInteractionSource, - indication = null, - onClick = { viewModel.outlineDelegate.collapseDialog() } - ) - .padding(vertical = 18.dp, horizontal = 20.dp), - contentAlignment = Alignment.Center - ) { - Icon( - painter = painterResource(Res.drawable.ic_outline_dialog_shrink), - contentDescription = "outline_collapse".i18n(), - tint = Color(0xFF666666), - modifier = Modifier.size(24.dp) - ) - } - - val closeInteractionSource = remember { MutableInteractionSource() } - val isClosePressed by closeInteractionSource.collectIsPressedAsState() - - Box( - modifier = Modifier - .alpha(if (isClosePressed) 0.5f else 1f) - .clickable( - interactionSource = closeInteractionSource, - indication = null, - onClick = { viewModel.outlineDelegate.hideDialog() } - ) - .padding(vertical = 18.dp, horizontal = 20.dp), - contentAlignment = Alignment.Center - ) { - Icon( - painter = painterResource(Res.drawable.ic_outline_dialog_close), - contentDescription = "btn_close".i18n(), - tint = Color(0xFF666666), - modifier = Modifier.size(24.dp) - ) - } - } - Box( modifier = Modifier + .padding(top = 32.dp) .padding(horizontal = 20.dp) .weight(1f) .fillMaxWidth() @@ -257,6 +207,80 @@ private fun ExpandedOutlineDialog() { } } } + + Row( + modifier = Modifier + .fillMaxWidth() + .height(55.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val collapseInteractionSource = remember { MutableInteractionSource() } + val isCollapsePressed by collapseInteractionSource.collectIsPressedAsState() + + Row( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .alpha(if (isCollapsePressed) 0.5f else 1f) + .clickable( + interactionSource = collapseInteractionSource, + indication = null, + onClick = { viewModel.outlineDelegate.collapseDialog() } + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally) + ) { + Icon( + painter = painterResource(Res.drawable.ic_outline_dialog_shrink), + contentDescription = "outline_collapse".i18n(), + tint = Color(0xFF666666), + modifier = Modifier.size(24.dp) + ) + Text( + text = "outline_minimize".i18n(), + fontSize = 14.sp, + lineHeight = 20.sp, + color = Color(0xCC333333) + ) + } + + Box( + modifier = Modifier + .width(1.dp) + .height(16.dp) + .background(Color(0x14333333)) + ) + + val closeInteractionSource = remember { MutableInteractionSource() } + val isClosePressed by closeInteractionSource.collectIsPressedAsState() + + Row( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .alpha(if (isClosePressed) 0.5f else 1f) + .clickable( + interactionSource = closeInteractionSource, + indication = null, + onClick = { viewModel.outlineDelegate.hideDialog() } + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally) + ) { + Icon( + painter = painterResource(Res.drawable.ic_outline_dialog_close), + contentDescription = "btn_close".i18n(), + tint = Color(0xFF666666), + modifier = Modifier.size(24.dp) + ) + Text( + text = "btn_close".i18n(), + fontSize = 14.sp, + lineHeight = 20.sp, + color = Color(0xCC333333) + ) + } + } } } } From 9d7667b9692bd8c151f3f170b62d3e11699dd9e1 Mon Sep 17 00:00:00 2001 From: YaoJunchang Date: Fri, 13 Mar 2026 14:13:25 +0800 Subject: [PATCH 06/31] =?UTF-8?q?=F0=9F=8E=A8=20enhance=20animation=20for?= =?UTF-8?q?=20OutlineDialog=20and=20FloatingActionBar=20for=20improved=20u?= =?UTF-8?q?ser=20experience?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../drawable/ic_outline_collapsed.png | Bin 0 -> 342 bytes .../bookmark/components/FloatingActionBar.kt | 19 + .../ui/bookmark/components/OutlineDialog.kt | 337 +++++++----------- 3 files changed, 148 insertions(+), 208 deletions(-) create mode 100644 composeApp/src/commonMain/composeResources/drawable/ic_outline_collapsed.png diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_outline_collapsed.png b/composeApp/src/commonMain/composeResources/drawable/ic_outline_collapsed.png new file mode 100644 index 0000000000000000000000000000000000000000..e95013add59338858cb41f627b5af3c93765de01 GIT binary patch literal 342 zcmeAS@N?(olHy`uVBq!ia0vp^0U*r63?ysp-_HY5x&b~Ru0Yxt224y$w6(P%JR>6` zn6NQW90-8i>*^-5Kn;8)L4Lsu9nTiUv3@z7zE@(lkHza#H5oveQ=Tr4As)w*C0G|H z7&Lv`!nNqi+M4h0?&>6ooMYCi;Y{7kwYRz1ebI_n76%sb3h+I)R{02S$c>=6xq}wjF*2 P^c91rtDnm{r-UW|yVQJ{ literal 0 HcmV?d00001 diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/FloatingActionBar.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/FloatingActionBar.kt index fb2ecb5a..79bc3d69 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/FloatingActionBar.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/FloatingActionBar.kt @@ -1,6 +1,7 @@ package com.slax.reader.ui.bookmark.components import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background @@ -40,10 +41,20 @@ fun FloatingActionBar( val isStarred by remember { derivedStateOf { detailState.isStarred } } val isArchived by remember { derivedStateOf { detailState.isArchived } } + // 观察大纲收缩状态,用于整体居中动画 + val outlineStatus by viewModel.outlineDelegate.dialogStatus.collectAsState() + val isOutlineCollapsed by remember { derivedStateOf { outlineStatus == com.slax.reader.ui.bookmark.states.OutlineDialogStatus.COLLAPSED } } + val density = LocalDensity.current val hiddenOffsetPx = remember(density) { with(density) { 150.dp.toPx() } } val translationY = remember { Animatable(if (visible) 0f else hiddenOffsetPx) } + // 水平偏移动画:收缩态时向右偏移 31dp,与 CollapsedOutlineButton 整体居中 + // 计算依据:组合总宽 224dp = Button(50) + Gap(12) + FAB(162) + // FAB 需向右偏移 (224/2 - 162/2) = 31dp + val collapsedOffsetPx = remember(density) { with(density) { 31.dp.toPx() } } + val translationX = remember { Animatable(0f) } + LaunchedEffect(visible) { translationY.animateTo( targetValue = if (visible) 0f else hiddenOffsetPx, @@ -51,9 +62,17 @@ fun FloatingActionBar( ) } + LaunchedEffect(isOutlineCollapsed) { + translationX.animateTo( + targetValue = if (isOutlineCollapsed) collapsedOffsetPx else 0f, + animationSpec = tween(durationMillis = 350, easing = FastOutSlowInEasing) + ) + } + Box( modifier = modifier.graphicsLayer { this.translationY = translationY.value + this.translationX = translationX.value } ) { Row( diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt index 6397c2d5..15c43d16 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt @@ -2,6 +2,7 @@ package com.slax.reader.ui.bookmark.components import androidx.compose.animation.* import androidx.compose.animation.core.* +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -18,25 +19,26 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.draw.drawBehind import com.slax.reader.ui.bookmark.BookmarkDetailViewModel import com.slax.reader.ui.bookmark.states.OutlineDialogStatus import com.slax.reader.utils.i18n import org.jetbrains.compose.resources.painterResource import org.koin.compose.viewmodel.koinViewModel import slax_reader_client.composeapp.generated.resources.Res -import slax_reader_client.composeapp.generated.resources.ic_outline_banner_analyzed -import slax_reader_client.composeapp.generated.resources.ic_outline_banner_analyzing -import slax_reader_client.composeapp.generated.resources.ic_outline_banner_close -import slax_reader_client.composeapp.generated.resources.ic_outline_banner_expand +import slax_reader_client.composeapp.generated.resources.ic_outline_collapsed import slax_reader_client.composeapp.generated.resources.ic_outline_dialog_close import slax_reader_client.composeapp.generated.resources.ic_outline_dialog_shrink +import androidx.compose.ui.draw.dropShadow +import androidx.compose.ui.graphics.shadow.Shadow +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin @Composable fun OutlineDialog() { @@ -53,19 +55,24 @@ fun OutlineDialog() { } if (status == OutlineDialogStatus.NONE) return - val isExpanded = status == OutlineDialogStatus.EXPANDED - val isCollapsed = status == OutlineDialogStatus.COLLAPSED + + // 使用 updateTransition 驱动协调动画 + val transition = updateTransition(targetState = status, label = "outlineTransition") Box(modifier = Modifier.fillMaxSize()) { - AnimatedVisibility( - visible = isExpanded, - enter = fadeIn(animationSpec = tween(300)), - exit = fadeOut(animationSpec = tween(300)) - ) { + // 1. 背景遮罩(仅 EXPANDED 时可见) + val bgAlpha by transition.animateFloat( + label = "bgAlpha", + transitionSpec = { tween(300) } + ) { state -> + if (state == OutlineDialogStatus.EXPANDED) 0.5f else 0f + } + + if (bgAlpha > 0f) { Box( modifier = Modifier .fillMaxSize() - .background(Color.Black.copy(alpha = 0.5f)) + .background(Color.Black.copy(alpha = bgAlpha)) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, @@ -74,62 +81,41 @@ fun OutlineDialog() { ) } - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.BottomCenter + // 2. 展开态弹窗(从底部弹出 + 淡入,向下掉落 + 淡出) + transition.AnimatedVisibility( + visible = { it == OutlineDialogStatus.EXPANDED }, + enter = slideInVertically(tween(300, easing = FastOutSlowInEasing)) { it } + fadeIn(tween(250)), + exit = slideOutVertically(tween(250, easing = FastOutSlowInEasing)) { it } + fadeOut(tween(200)) ) { - AnimatedVisibility( - visible = isExpanded, - enter = slideInVertically( - initialOffsetY = { it }, - animationSpec = tween(300) - ), - exit = slideOutVertically( - targetOffsetY = { it }, - animationSpec = tween(300) - ) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomCenter ) { ExpandedOutlineDialog() } } - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.TopCenter + // 3. 收缩态按钮(简单淡入淡出) + // 偏移量:整体居中后,按钮中心在屏幕中心左偏 87dp + // 计算依据:组合总宽 224dp = Button(50) + Gap(12) + FAB(162) + // Button 中心 = -(224/2 - 50/2) = -87dp + transition.AnimatedVisibility( + visible = { it == OutlineDialogStatus.COLLAPSED }, + enter = fadeIn(tween(200)), + exit = fadeOut(tween(200)) ) { - AnimatedVisibility( - visible = isCollapsed, - enter = scaleIn( - initialScale = 0.3f, - animationSpec = tween(350, delayMillis = 100) - ) + fadeIn( - animationSpec = tween(300, delayMillis = 100) - ), - exit = if (isExpanded) { - slideOutVertically( - targetOffsetY = { it }, - animationSpec = tween(350) - ) + scaleOut( - targetScale = 3.0f, - animationSpec = tween(350) - ) + fadeOut( - animationSpec = tween(250) - ) - } else { - scaleOut( - targetScale = 0.3f, - animationSpec = tween(300) - ) + fadeOut( - animationSpec = tween(250) - ) - } + Box( + modifier = Modifier + .fillMaxSize() + .padding(bottom = 58.dp) + .offset(x = (-87).dp), + contentAlignment = Alignment.BottomCenter ) { - CollapsedOutlineBanner() + CollapsedOutlineButton() } } } } - /** * 全屏展开状态的弹窗 */ @@ -286,146 +272,102 @@ private fun ExpandedOutlineDialog() { } /** - * 收缩状态的小banner - * 根据 outline 加载状态显示不同的文本和图标 + * 收缩态圆形按钮,样式与 FloatingActionBar 中的 MoreButton 完全一致 + * 总结中状态叠加 dot 旋转加载环 */ @Composable -private fun CollapsedOutlineBanner() { - val viewModel = koinViewModel() - - // 订阅状态 +private fun CollapsedOutlineButton() { + val viewModel = koinViewModel() val outlineState by viewModel.outlineDelegate.outlineState.collectAsState() - val uiState by viewModel.bookmarkDelegate.bookmarkDetailState.collectAsState() - val displayTitle by remember { derivedStateOf { uiState.displayTitle } } - - // 根据状态确定显示内容 val isLoading = outlineState.isLoading - val isCompleted = !isLoading && outlineState.outline.isNotEmpty() && outlineState.error == null - Surface( - shape = RoundedCornerShape(16.dp), - color = Color.White, - shadowElevation = 4.dp, - modifier = Modifier - .fillMaxWidth() - .statusBarsPadding() // 添加状态栏间距 - .padding(horizontal = 16.dp, vertical = 8.dp) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { - viewModel.outlineDelegate.expandDialog() - } - ) { - Row( + Box(contentAlignment = Alignment.Center) { + // 阴影层(与 MoreButton 一致) + Box( modifier = Modifier - .fillMaxWidth() - .padding(vertical = 17.dp, horizontal = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + .size(50.dp) + .offset(y = 10.dp) + .dropShadow( + shape = RoundedCornerShape(25.dp), + shadow = Shadow( + radius = 40.dp, + spread = 0.dp, + color = Color.Black.copy(alpha = 0.16f), + offset = DpOffset(x = 0.dp, y = 10.dp) + ) + ) + ) + + // 按钮主体(与 MoreButton 一致) + Surface( + onClick = { viewModel.outlineDelegate.expandDialog() }, + modifier = Modifier.size(50.dp), + color = Color(0xFFFFFFFF), + shape = RoundedCornerShape(25.dp), + border = BorderStroke(1.dp, Color.White) ) { - // 左边:图标 + 文本 - Row( - modifier = Modifier.weight(1f), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - // 状态图标 + Box(contentAlignment = Alignment.Center) { Icon( - painter = painterResource( - if (isCompleted) { - Res.drawable.ic_outline_banner_analyzed - } else { - Res.drawable.ic_outline_banner_analyzing - } - ), - contentDescription = if (isCompleted) "outline_completed".i18n() else "outline_summarizing".i18n(), - tint = Color.Unspecified, // 使用原始颜色 + painter = painterResource(Res.drawable.ic_outline_collapsed), + contentDescription = "outline_expand".i18n(), + tint = Color.Unspecified, modifier = Modifier.size(20.dp) ) - - // 状态文本(使用 AnnotatedString 实现不同颜色) - Text( - modifier = Modifier.padding(start = 8.dp), - text = buildAnnotatedString { - withStyle( - style = SpanStyle( - color = if (isCompleted) Color(0xFF16B998) else Color(0xFFA28D64), - fontSize = 15.sp - ) - ) { - append(if (isCompleted) "outline_completed_prefix".i18n() else "outline_summarizing_prefix".i18n()) - } - withStyle( - style = SpanStyle( - color = Color(0xFF333333), - fontSize = 15.sp - ) - ) { - append(displayTitle) - } - }, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) } + } - // 分割线 - Box( - modifier = Modifier - .padding(start = 16.dp, end = 12.dp) - .width(1.dp) - .height(16.dp) - .background(Color(0x3DCAA68E)) - ) + // 总结中状态叠加 dot 旋转加载环 + if (isLoading) { + DotLoadingRing(modifier = Modifier.size(27.dp)) + } + } +} - if (isCompleted) { - // 展开按钮 - val expandInteractionSource = remember { MutableInteractionSource() } - val isExpandPressed by expandInteractionSource.collectIsPressedAsState() +/** + * dot 旋转加载环 + * 20 个 dot 均匀分布在圆周上,颜色从 #16B998 渐变到 #ECF9F6,匀速旋转 + */ +@Composable +private fun DotLoadingRing(modifier: Modifier = Modifier) { + val infiniteTransition = rememberInfiniteTransition(label = "dotRingRotation") + val rotation by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1500, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "rotation" + ) - Box( - modifier = Modifier - .alpha(if (isExpandPressed) 0.5f else 1f) - .clickable( - interactionSource = expandInteractionSource, - indication = null, - onClick = { viewModel.outlineDelegate.expandDialog() } - ) - .padding(4.dp), - contentAlignment = Alignment.Center - ) { - Icon( - painter = painterResource(Res.drawable.ic_outline_banner_expand), - contentDescription = "outline_expand".i18n(), - modifier = Modifier.size(12.dp) - ) - } - } else { - // 关闭按钮 - val closeBannerInteractionSource = remember { MutableInteractionSource() } - val isCloseBannerPressed by closeBannerInteractionSource.collectIsPressedAsState() + val startColor = Color(0xFF16B998) + val endColor = Color(0xFFECF9F6) + val dotCount = 20 - Box( - modifier = Modifier - .alpha(if (isCloseBannerPressed) 0.5f else 1f) - .clickable( - interactionSource = closeBannerInteractionSource, - indication = null, - onClick = { viewModel.outlineDelegate.hideDialog() } - ) - .padding(4.dp), - contentAlignment = Alignment.Center - ) { - Icon( - painter = painterResource(Res.drawable.ic_outline_banner_close), - contentDescription = "btn_close".i18n(), - modifier = Modifier.size(12.dp) - ) - } + val dotColors = remember { + List(dotCount) { i -> lerp(startColor, endColor, i.toFloat() / dotCount) } + } + + Box( + modifier = modifier.graphicsLayer { rotationZ = rotation }.drawBehind { + val radius = size.minDimension / 2f + val dotRadius = 1.5.dp.toPx() / 2f + val center = androidx.compose.ui.geometry.Offset(size.width / 2f, size.height / 2f) + + for (i in 0 until dotCount) { + val angle = (2.0 * PI * i / dotCount - PI / 2).toFloat() + val dotCenter = androidx.compose.ui.geometry.Offset( + x = center.x + radius * cos(angle), + y = center.y + radius * sin(angle) + ) + drawCircle( + color = dotColors[i], + radius = dotRadius, + center = dotCenter + ) } } - } + ) } /** @@ -470,23 +412,6 @@ private fun LoadingAnimation() { } } -/** - * 空状态视图 - */ -@Composable -private fun EmptyView() { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - text = "outline_empty".i18n(), - fontSize = 14.sp, - color = Color(0xFF999999) - ) - } -} - /** * 错误状态视图 */ @@ -534,7 +459,7 @@ private fun DotLoadingAnimation() { Row( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.height(8.dp) // 留出上下移动的空间 + modifier = Modifier.height(8.dp) ) { dotColors.forEachIndexed { index, color -> val offsetY by infiniteTransition.animateFloat( @@ -543,13 +468,9 @@ private fun DotLoadingAnimation() { animationSpec = infiniteRepeatable( animation = keyframes { durationMillis = 1400 - // 0%, 100% 0f at 0 - // 25% - -2.4f at 350 // 1400 * 0.25 - // 75% - 2.4f at 1050 // 1400 * 0.75 - // 100% + -2.4f at 350 + 2.4f at 1050 0f at 1400 }, repeatMode = RepeatMode.Restart, @@ -571,4 +492,4 @@ private fun DotLoadingAnimation() { ) } } -} \ No newline at end of file +} From 84b10284e1c22dc51adfac28b25c82fa160b55e9 Mon Sep 17 00:00:00 2001 From: YaoJunchang Date: Fri, 13 Mar 2026 14:32:18 +0800 Subject: [PATCH 07/31] =?UTF-8?q?=F0=9F=8E=A8=20add=20confirmation=20dialo?= =?UTF-8?q?g=20for=20bookmark=20deletion=20with=20localization=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/slax/reader/const/LocaleString.kt | 8 ++++ .../slax/reader/ui/bookmark/DetailScreen.kt | 45 ------------------- .../bookmark/components/BottomToolbarSheet.kt | 34 +++++++++++++- .../ui/bookmark/components/OutlineDialog.kt | 7 +-- .../ui/bookmark/components/OverviewView.kt | 2 +- .../ui/bookmark/states/BookmarkDelegate.kt | 33 +++++--------- 6 files changed, 54 insertions(+), 75 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/const/LocaleString.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/const/LocaleString.kt index 5074c50d..8e48691a 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/const/LocaleString.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/const/LocaleString.kt @@ -248,6 +248,14 @@ val localeString: Map> = mapOf( "zh" to "删除", "en" to "Delete" ), + "bookmark_delete_confirm_title" to mapOf( + "zh" to "确认删除", + "en" to "Confirm Delete" + ), + "bookmark_delete_confirm_message" to mapOf( + "zh" to "确定要删除吗?", + "en" to "Are you sure you want to delete?" + ), // ======================================== // 关于页面 diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/DetailScreen.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/DetailScreen.kt index c0fa2055..3832cb2a 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/DetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/DetailScreen.kt @@ -1,19 +1,6 @@ package com.slax.reader.ui.bookmark import androidx.compose.runtime.* -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Text -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties import com.slax.reader.ui.bookmark.components.DetailScreenSkeleton import com.slax.reader.ui.bookmark.states.ScrollInfo import com.slax.reader.utils.* @@ -105,44 +92,12 @@ fun DetailScreen(bookmarkId: String, onEvent: (DetailScreenEvent) -> Unit) { } val contentState by viewModel.contentState.collectAsState() - val detailState by viewModel.bookmarkDelegate.bookmarkDetailState.collectAsState() if (contentState.htmlContent == null || contentState.isLoading) { DetailScreenSkeleton() return } - // 删除加载对话框 - if (detailState.isDeleting) { - Dialog( - onDismissRequest = { }, - properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false) - ) { - Box( - modifier = Modifier - .size(200.dp) - .background(Color.White, shape = RoundedCornerShape(8.dp)) - .padding(24.dp), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - CircularProgressIndicator( - color = Color(0xFF16B998), - strokeWidth = 3.dp, - modifier = Modifier.size(48.dp) - ) - Text( - text = "删除中...", - style = TextStyle(fontSize = 16.sp) - ) - } - } - } - } - CompositionLocalProvider(LocalToolbarVisible provides toolbarVisible) { DetailScreen( htmlContent = contentState.htmlContent!!, diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/BottomToolbarSheet.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/BottomToolbarSheet.kt index 26f35240..e67716ef 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/BottomToolbarSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/BottomToolbarSheet.kt @@ -10,7 +10,10 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -41,6 +44,7 @@ fun BottomToolbarSheet() { println("[watch][UI] recomposition BottomToolbarSheet") val viewModel = koinViewModel() val detailState by viewModel.bookmarkDelegate.bookmarkDetailState.collectAsState() + var showDeleteConfirm by remember { mutableStateOf(false) } val toolbarPages = remember(detailState.isStarred, detailState.isArchived) { listOf( @@ -121,12 +125,38 @@ fun BottomToolbarSheet() { PagerToolbar( pages = toolbarPages, onIconClick = { pageId, iconIndex -> - viewModel.onToolbarIconClick(pageId) - dismiss() + if (pageId == "delete") { + showDeleteConfirm = true + } else { + viewModel.onToolbarIconClick(pageId) + dismiss() + } }, modifier = Modifier.padding(top = 30.dp, bottom = 50.dp) ) } } } + + if (showDeleteConfirm) { + AlertDialog( + onDismissRequest = { showDeleteConfirm = false }, + containerColor = Color.White, + title = { Text(text = "bookmark_delete_confirm_title".i18n()) }, + text = { Text(text = "bookmark_delete_confirm_message".i18n()) }, + confirmButton = { + TextButton(onClick = { + showDeleteConfirm = false + viewModel.onToolbarIconClick("delete") + }) { + Text(text = "btn_confirm".i18n(), color = Color(0xFFF45454)) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirm = false }) { + Text(text = "btn_cancel".i18n()) + } + } + ) + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt index 15c43d16..299f77b9 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt @@ -272,8 +272,7 @@ private fun ExpandedOutlineDialog() { } /** - * 收缩态圆形按钮,样式与 FloatingActionBar 中的 MoreButton 完全一致 - * 总结中状态叠加 dot 旋转加载环 + * 收缩态圆形按钮 */ @Composable private fun CollapsedOutlineButton() { @@ -282,7 +281,6 @@ private fun CollapsedOutlineButton() { val isLoading = outlineState.isLoading Box(contentAlignment = Alignment.Center) { - // 阴影层(与 MoreButton 一致) Box( modifier = Modifier .size(50.dp) @@ -298,7 +296,6 @@ private fun CollapsedOutlineButton() { ) ) - // 按钮主体(与 MoreButton 一致) Surface( onClick = { viewModel.outlineDelegate.expandDialog() }, modifier = Modifier.size(50.dp), @@ -316,7 +313,6 @@ private fun CollapsedOutlineButton() { } } - // 总结中状态叠加 dot 旋转加载环 if (isLoading) { DotLoadingRing(modifier = Modifier.size(27.dp)) } @@ -325,7 +321,6 @@ private fun CollapsedOutlineButton() { /** * dot 旋转加载环 - * 20 个 dot 均匀分布在圆周上,颜色从 #16B998 渐变到 #ECF9F6,匀速旋转 */ @Composable private fun DotLoadingRing(modifier: Modifier = Modifier) { diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OverviewView.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OverviewView.kt index 5f9732d7..bad9be0e 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OverviewView.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OverviewView.kt @@ -219,7 +219,7 @@ private fun TextWithTrailingIcon( textLayoutResult?.let { layout -> if (layout.lineCount > 0) { val lastLine = minOf(layout.lineCount - 1, 2) - val lineTop = layout.getLineTop(lastLine) + val lineTop = layout.getLineTop(lastLine) + 10 val lineBottom = layout.getLineBottom(lastLine) val lineRight = layout.getLineRight(lastLine) diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/BookmarkDelegate.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/BookmarkDelegate.kt index 3b85d8a6..cbc132e1 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/BookmarkDelegate.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/BookmarkDelegate.kt @@ -21,7 +21,6 @@ data class BookmarkDetailState( val displayTitle: String = "", val displayTime: String = "", val metadataUrl: String? = null, - val isDeleting: Boolean = false, ) class BookmarkDelegate( @@ -29,8 +28,6 @@ class BookmarkDelegate( private val bookmarkIdFlow: StateFlow, private val scope: CoroutineScope ) { - private val _isDeleting = MutableStateFlow(false) - @OptIn(ExperimentalCoroutinesApi::class) private val bookmarkFlow: StateFlow> = bookmarkIdFlow .filterNotNull() @@ -38,21 +35,18 @@ class BookmarkDelegate( .distinctUntilChanged() .stateIn(scope, SharingStarted.WhileSubscribed(5000), emptyList()) - val bookmarkDetailState: StateFlow = combine( - bookmarkFlow, - _isDeleting - ) { list, isDeleting -> - list.firstOrNull()?.let { b -> - BookmarkDetailState( - isStarred = b.isStarred == 1, - isArchived = b.archiveStatus == 1, - displayTitle = b.displayTitle, - displayTime = b.displayTime, - metadataUrl = b.metadataUrl, - isDeleting = isDeleting, - ) - } ?: BookmarkDetailState(isDeleting = isDeleting) - } + val bookmarkDetailState: StateFlow = bookmarkFlow + .map { list -> + list.firstOrNull()?.let { b -> + BookmarkDetailState( + isStarred = b.isStarred == 1, + isArchived = b.archiveStatus == 1, + displayTitle = b.displayTitle, + displayTime = b.displayTime, + metadataUrl = b.metadataUrl, + ) + } ?: BookmarkDetailState() + } .distinctUntilChanged() .stateIn(scope, SharingStarted.WhileSubscribed(5000), BookmarkDetailState()) @@ -106,16 +100,13 @@ class BookmarkDelegate( fun onDeleteBookmark(onSuccess: () -> Unit, onFailure: () -> Unit) { scope.launch { - _isDeleting.value = true runCatching { bookmarkIdFlow.value?.let { id -> bookmarkDao.deleteBookmark(id) } }.onSuccess { - _isDeleting.value = false onSuccess() }.onFailure { - _isDeleting.value = false onFailure() } } From 36f33a6d0452aa1ff37038d82abc1c8b7c76ad04 Mon Sep 17 00:00:00 2001 From: YaoJunchang Date: Mon, 16 Mar 2026 09:49:46 +0800 Subject: [PATCH 08/31] =?UTF-8?q?=F0=9F=8E=A8=20enhance=20animation=20tran?= =?UTF-8?q?sitions=20in=20OutlineDialog=20for=20improved=20user=20experien?= =?UTF-8?q?ce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/bookmark/components/OutlineDialog.kt | 214 +++++++++++++++--- 1 file changed, 187 insertions(+), 27 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt index 299f77b9..b1f36ce8 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt @@ -20,9 +20,11 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.lerp as lerpDp import androidx.compose.ui.unit.sp import androidx.compose.ui.draw.drawBehind import com.slax.reader.ui.bookmark.BookmarkDetailViewModel @@ -56,18 +58,127 @@ fun OutlineDialog() { if (status == OutlineDialogStatus.NONE) return - // 使用 updateTransition 驱动协调动画 val transition = updateTransition(targetState = status, label = "outlineTransition") - Box(modifier = Modifier.fillMaxSize()) { - // 1. 背景遮罩(仅 EXPANDED 时可见) - val bgAlpha by transition.animateFloat( - label = "bgAlpha", - transitionSpec = { tween(300) } - ) { state -> - if (state == OutlineDialogStatus.EXPANDED) 0.5f else 0f + // === 背景遮罩透明度 === + val bgAlpha by transition.animateFloat( + label = "bgAlpha", + transitionSpec = { tween(300) } + ) { state -> + if (state == OutlineDialogStatus.EXPANDED) 0.5f else 0f + } + + // === 展开弹窗透明度(方向感知:EXPANDED↔HIDDEN 标准淡入淡出,EXPANDED↔COLLAPSED 快速配合变形)=== + val expandedAlpha by transition.animateFloat( + label = "expandedAlpha", + transitionSpec = { + when { + OutlineDialogStatus.HIDDEN isTransitioningTo OutlineDialogStatus.EXPANDED -> + tween(250) + OutlineDialogStatus.COLLAPSED isTransitioningTo OutlineDialogStatus.EXPANDED -> + tween(180, delayMillis = 380) + OutlineDialogStatus.EXPANDED isTransitioningTo OutlineDialogStatus.COLLAPSED -> + tween(120) + OutlineDialogStatus.EXPANDED isTransitioningTo OutlineDialogStatus.HIDDEN -> + tween(200) + else -> snap() + } + } + ) { state -> if (state == OutlineDialogStatus.EXPANDED) 1f else 0f } + + // === 展开弹窗滑动偏移(仅 HIDDEN↔EXPANDED 方向有效,其他方向 snap 到 0)=== + val expandedSlideOffset by transition.animateFloat( + label = "expandedSlideOffset", + transitionSpec = { + when { + OutlineDialogStatus.HIDDEN isTransitioningTo OutlineDialogStatus.EXPANDED -> + tween(300, easing = FastOutSlowInEasing) + OutlineDialogStatus.EXPANDED isTransitioningTo OutlineDialogStatus.HIDDEN -> + tween(250, easing = FastOutSlowInEasing) + else -> snap() + } } + ) { state -> + when (state) { + OutlineDialogStatus.EXPANDED -> 0f + OutlineDialogStatus.HIDDEN -> 1f + else -> 0f + } + } + // === 收缩按钮透明度(方向感知:EXPANDED↔COLLAPSED 时配合变形时序,其他方向标准淡入淡出)=== + val collapsedAlpha by transition.animateFloat( + label = "collapsedAlpha", + transitionSpec = { + when { + OutlineDialogStatus.EXPANDED isTransitioningTo OutlineDialogStatus.COLLAPSED -> + tween(150, delayMillis = 430) + OutlineDialogStatus.COLLAPSED isTransitioningTo OutlineDialogStatus.EXPANDED -> + tween(100) + OutlineDialogStatus.HIDDEN isTransitioningTo OutlineDialogStatus.COLLAPSED -> + tween(200) + OutlineDialogStatus.COLLAPSED isTransitioningTo OutlineDialogStatus.HIDDEN -> + tween(200) + else -> snap() + } + } + ) { state -> if (state == OutlineDialogStatus.COLLAPSED) 1f else 0f } + + // === 变形进度:0f = 收缩圆形位置,1f = 展开矩形位置(仅 EXPANDED↔COLLAPSED 时动画)=== + val morphProgress by transition.animateFloat( + label = "morphProgress", + transitionSpec = { + when { + OutlineDialogStatus.EXPANDED isTransitioningTo OutlineDialogStatus.COLLAPSED -> + tween(380, delayMillis = 100, easing = FastOutSlowInEasing) + OutlineDialogStatus.COLLAPSED isTransitioningTo OutlineDialogStatus.EXPANDED -> + tween(380, delayMillis = 80, easing = FastOutSlowInEasing) + else -> snap() + } + } + ) { state -> if (state == OutlineDialogStatus.EXPANDED) 1f else 0f } + + // === 变形遮罩透明度(keyframes 精确控制:仅在 EXPANDED↔COLLAPSED 过渡期间可见)=== + val morphAlpha by transition.animateFloat( + label = "morphAlpha", + transitionSpec = { + when { + OutlineDialogStatus.EXPANDED isTransitioningTo OutlineDialogStatus.COLLAPSED -> + keyframes { + durationMillis = 580 + 0f at 0 using LinearEasing + 1f at 80 using LinearEasing + 1f at 480 using LinearEasing + 0f at 580 + } + OutlineDialogStatus.COLLAPSED isTransitioningTo OutlineDialogStatus.EXPANDED -> + keyframes { + durationMillis = 560 + 0f at 0 using LinearEasing + 1f at 60 using LinearEasing + 1f at 460 using LinearEasing + 0f at 560 + } + else -> snap() + } + } + ) { 0f } + + // 用 transition 的当前/目标状态控制子组件的渲染,避免透明但仍占用资源的情况 + val expandedVisible = transition.currentState == OutlineDialogStatus.EXPANDED || + transition.targetState == OutlineDialogStatus.EXPANDED + val collapsedVisible = transition.currentState == OutlineDialogStatus.COLLAPSED || + transition.targetState == OutlineDialogStatus.COLLAPSED + + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val screenWidth = maxWidth + val screenHeight = maxHeight + val density = LocalDensity.current + val screenHeightPx = with(density) { screenHeight.toPx() } + val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val dialogHeight = screenHeight - statusBarHeight - 36.dp + + // 1. 背景遮罩(仅 EXPANDED 时可见) if (bgAlpha > 0f) { Box( modifier = Modifier @@ -81,34 +192,39 @@ fun OutlineDialog() { ) } - // 2. 展开态弹窗(从底部弹出 + 淡入,向下掉落 + 淡出) - transition.AnimatedVisibility( - visible = { it == OutlineDialogStatus.EXPANDED }, - enter = slideInVertically(tween(300, easing = FastOutSlowInEasing)) { it } + fadeIn(tween(250)), - exit = slideOutVertically(tween(250, easing = FastOutSlowInEasing)) { it } + fadeOut(tween(200)) - ) { + // 2. 展开态弹窗(graphicsLayer 控制 alpha + translationY,不触发重新布局) + if (expandedVisible) { Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + alpha = expandedAlpha + translationY = expandedSlideOffset * screenHeightPx + }, contentAlignment = Alignment.BottomCenter ) { ExpandedOutlineDialog() } } - // 3. 收缩态按钮(简单淡入淡出) - // 偏移量:整体居中后,按钮中心在屏幕中心左偏 87dp - // 计算依据:组合总宽 224dp = Button(50) + Gap(12) + FAB(162) - // Button 中心 = -(224/2 - 50/2) = -87dp - transition.AnimatedVisibility( - visible = { it == OutlineDialogStatus.COLLAPSED }, - enter = fadeIn(tween(200)), - exit = fadeOut(tween(200)) - ) { + // 3. 变形遮罩层(仅 EXPANDED↔COLLAPSED 过渡期间存在,用于模拟容器变形效果) + if (morphAlpha > 0f) { + MorphOverlay( + morphAlpha = morphAlpha, + morphProgress = morphProgress, + screenWidth = screenWidth, + dialogHeight = dialogHeight + ) + } + + // 4. 收缩态按钮(graphicsLayer 控制 alpha) + if (collapsedVisible) { Box( modifier = Modifier .fillMaxSize() .padding(bottom = 58.dp) - .offset(x = (-87).dp), + .offset(x = (-87).dp) + .graphicsLayer { alpha = collapsedAlpha }, contentAlignment = Alignment.BottomCenter ) { CollapsedOutlineButton() @@ -116,12 +232,56 @@ fun OutlineDialog() { } } } + +/** + * 变形遮罩层:在 EXPANDED ↔ COLLAPSED 过渡期间,以白色形状模拟容器从矩形变形为圆形的效果。 + * + * morphProgress:0f = 收缩态(50dp 圆形,位于按钮位置),1f = 展开态(全宽矩形,位于弹窗位置) + * morphAlpha:由 keyframes 控制,仅在过渡期间可见,静止时始终为 0f。 + */ +@Composable +private fun MorphOverlay( + morphAlpha: Float, + morphProgress: Float, + screenWidth: androidx.compose.ui.unit.Dp, + dialogHeight: androidx.compose.ui.unit.Dp +) { + val width = lerpDp(50.dp, screenWidth, morphProgress) + val height = lerpDp(50.dp, dialogHeight, morphProgress) + val topCorner = lerpDp(25.dp, 20.dp, morphProgress) + val bottomCorner = lerpDp(25.dp, 0.dp, morphProgress) + val offsetX = lerpDp((-87).dp, 0.dp, morphProgress) + val bottomPadding = lerpDp(58.dp, 0.dp, morphProgress) + + Box( + modifier = Modifier + .fillMaxSize() + .padding(bottom = bottomPadding), + contentAlignment = Alignment.BottomCenter + ) { + Surface( + modifier = Modifier + .offset(x = offsetX) + .size(width, height) + .graphicsLayer { alpha = morphAlpha }, + shape = RoundedCornerShape( + topStart = topCorner, + topEnd = topCorner, + bottomStart = bottomCorner, + bottomEnd = bottomCorner + ), + color = Color.White, + shadowElevation = lerpDp(0.dp, 8.dp, morphProgress) + ) {} + } +} + /** * 全屏展开状态的弹窗 */ @Composable private fun ExpandedOutlineDialog() { - val viewModel = koinViewModel() + val viewModel = koinViewModel() val outlineState by viewModel.outlineDelegate.outlineState.collectAsState() Surface( @@ -487,4 +647,4 @@ private fun DotLoadingAnimation() { ) } } -} +} \ No newline at end of file From fa7ed12bf2307326d1dd28c869661f96dc7d29a8 Mon Sep 17 00:00:00 2001 From: YaoJunchang Date: Mon, 16 Mar 2026 10:02:11 +0800 Subject: [PATCH 09/31] =?UTF-8?q?=F0=9F=8E=A8=20improve=20transition=20han?= =?UTF-8?q?dling=20and=20visibility=20logic=20in=20OutlineDialog=20for=20b?= =?UTF-8?q?etter=20performance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/bookmark/components/OutlineDialog.kt | 50 +++++++++---------- .../ui/bookmark/states/OutlineDelegate.kt | 12 +---- 2 files changed, 25 insertions(+), 37 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt index b1f36ce8..70e4d04f 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt @@ -58,7 +58,9 @@ fun OutlineDialog() { if (status == OutlineDialogStatus.NONE) return - val transition = updateTransition(targetState = status, label = "outlineTransition") + val transitionState = remember { MutableTransitionState(OutlineDialogStatus.HIDDEN) } + transitionState.targetState = status + val transition = rememberTransition(transitionState, label = "outlineTransition") // === 背景遮罩透明度 === val bgAlpha by transition.animateFloat( @@ -147,7 +149,7 @@ fun OutlineDialog() { keyframes { durationMillis = 580 0f at 0 using LinearEasing - 1f at 80 using LinearEasing + 1f at 0 using LinearEasing 1f at 480 using LinearEasing 0f at 580 } @@ -164,11 +166,10 @@ fun OutlineDialog() { } ) { 0f } - // 用 transition 的当前/目标状态控制子组件的渲染,避免透明但仍占用资源的情况 - val expandedVisible = transition.currentState == OutlineDialogStatus.EXPANDED || - transition.targetState == OutlineDialogStatus.EXPANDED - val collapsedVisible = transition.currentState == OutlineDialogStatus.COLLAPSED || - transition.targetState == OutlineDialogStatus.COLLAPSED + // 基于实际 alpha 值控制组合树的存在,alpha=0 时立即移除, + // 避免 Surface(shadowElevation) 的阴影在 graphicsLayer { alpha=0 } 下仍被平台渲染器绘制 + val expandedVisible = expandedAlpha > 0f + val collapsedVisible = status == OutlineDialogStatus.COLLAPSED || collapsedAlpha > 0f BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val screenWidth = maxWidth @@ -178,7 +179,6 @@ fun OutlineDialog() { val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val dialogHeight = screenHeight - statusBarHeight - 36.dp - // 1. 背景遮罩(仅 EXPANDED 时可见) if (bgAlpha > 0f) { Box( modifier = Modifier @@ -192,7 +192,15 @@ fun OutlineDialog() { ) } - // 2. 展开态弹窗(graphicsLayer 控制 alpha + translationY,不触发重新布局) + if (morphAlpha > 0f) { + MorphOverlay( + morphAlpha = morphAlpha, + morphProgress = morphProgress, + screenWidth = screenWidth, + dialogHeight = dialogHeight + ) + } + if (expandedVisible) { Box( modifier = Modifier @@ -207,27 +215,15 @@ fun OutlineDialog() { } } - // 3. 变形遮罩层(仅 EXPANDED↔COLLAPSED 过渡期间存在,用于模拟容器变形效果) - if (morphAlpha > 0f) { - MorphOverlay( - morphAlpha = morphAlpha, - morphProgress = morphProgress, - screenWidth = screenWidth, - dialogHeight = dialogHeight - ) - } - - // 4. 收缩态按钮(graphicsLayer 控制 alpha) if (collapsedVisible) { Box( modifier = Modifier .fillMaxSize() .padding(bottom = 58.dp) - .offset(x = (-87).dp) - .graphicsLayer { alpha = collapsedAlpha }, + .offset(x = (-87).dp), contentAlignment = Alignment.BottomCenter ) { - CollapsedOutlineButton() + CollapsedOutlineButton(collapsedAlpha) } } } @@ -270,8 +266,7 @@ private fun MorphOverlay( bottomStart = bottomCorner, bottomEnd = bottomCorner ), - color = Color.White, - shadowElevation = lerpDp(0.dp, 8.dp, morphProgress) + color = Color.White ) {} } } @@ -435,7 +430,7 @@ private fun ExpandedOutlineDialog() { * 收缩态圆形按钮 */ @Composable -private fun CollapsedOutlineButton() { +private fun CollapsedOutlineButton(animateAlpha: Float = 1f) { val viewModel = koinViewModel() val outlineState by viewModel.outlineDelegate.outlineState.collectAsState() val isLoading = outlineState.isLoading @@ -458,7 +453,8 @@ private fun CollapsedOutlineButton() { Surface( onClick = { viewModel.outlineDelegate.expandDialog() }, - modifier = Modifier.size(50.dp), + modifier = Modifier.size(50.dp) + .graphicsLayer { alpha = animateAlpha }, color = Color(0xFFFFFFFF), shape = RoundedCornerShape(25.dp), border = BorderStroke(1.dp, Color.White) diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/OutlineDelegate.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/OutlineDelegate.kt index 06dc508a..4ed777d2 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/OutlineDelegate.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/OutlineDelegate.kt @@ -13,7 +13,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.coroutines.yield + data class OutlineState( val outline: String = "", @@ -147,14 +147,6 @@ class OutlineDelegate( } private fun transitionTo(target: OutlineDialogStatus) { - if (_dialogStatus.value == OutlineDialogStatus.NONE) { - _dialogStatus.value = OutlineDialogStatus.HIDDEN - scope.launch { - yield() - _dialogStatus.value = target - } - } else { - _dialogStatus.value = target - } + _dialogStatus.value = target } } From e4d8733be1e92f0333c9f219ec5ac95a6bca0211 Mon Sep 17 00:00:00 2001 From: YaoJunchang Date: Mon, 16 Mar 2026 10:41:16 +0800 Subject: [PATCH 10/31] =?UTF-8?q?=F0=9F=8E=A8=20refactor=20OutlineDialog?= =?UTF-8?q?=20and=20add=20loading=20animations=20for=20improved=20UI=20res?= =?UTF-8?q?ponsiveness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reader/ui/bookmark/components/Loading.kt | 178 ++++++++++++++ .../ui/bookmark/components/OutlineDialog.kt | 225 ++++-------------- 2 files changed, 218 insertions(+), 185 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/Loading.kt diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/Loading.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/Loading.kt new file mode 100644 index 00000000..8828fc49 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/Loading.kt @@ -0,0 +1,178 @@ +package com.slax.reader.ui.bookmark.components + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.StartOffset +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.unit.dp +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin + + +/** + * 加载动画组件 + * 显示三个渐变色横条闪动效果 + */ +@Composable +fun LoadingAnimation() { + val infiniteTransition = rememberInfiniteTransition(label = "loadingAnimation") + val alpha by infiniteTransition.animateFloat( + initialValue = 0.3f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 800, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "alphaAnimation" + ) + + Column( + modifier = Modifier + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + repeat(3) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(18.dp) + .alpha(alpha) + .background( + brush = Brush.horizontalGradient( + colors = listOf( + Color(0xFFF5F5F3), + Color(0x99F5F5F3), + ) + ), + shape = RoundedCornerShape(4.dp) + ) + ) + } + } +} + +/** + * dot 旋转加载环 + */ +@Composable +fun DotLoadingRing(modifier: Modifier = Modifier) { + val infiniteTransition = rememberInfiniteTransition(label = "dotRingRotation") + val rotation by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1500, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "rotation" + ) + + val startColor = Color(0xFF16B998) + val endColor = Color(0xFFECF9F6) + val dotCount = 20 + + val dotColors = remember { + List(dotCount) { i -> lerp(startColor, endColor, i.toFloat() / dotCount) } + } + + Box( + modifier = modifier.graphicsLayer { rotationZ = rotation }.drawBehind { + val radius = size.minDimension / 2f + val dotRadius = 1.5.dp.toPx() / 2f + val center = androidx.compose.ui.geometry.Offset(size.width / 2f, size.height / 2f) + + for (i in 0 until dotCount) { + val angle = (2.0 * PI * i / dotCount - PI / 2).toFloat() + val dotCenter = androidx.compose.ui.geometry.Offset( + x = center.x + radius * cos(angle), + y = center.y + radius * sin(angle) + ) + drawCircle( + color = dotColors[i], + radius = dotRadius, + center = dotCenter + ) + } + } + ) +} + +/** + * 圆点加载组件 + */ +@Composable +fun DotLoadingAnimation() { + val infiniteTransition = rememberInfiniteTransition(label = "dotLoadingAnimation") + + val dotColors = listOf( + Color(0xFF16B998), + Color(0xFFFFC255), + Color(0xFF56CAF2), + Color(0xFFFB8F6C) + ) + + val delays = listOf(0, 250, 500, 750) + + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.height(8.dp) + ) { + dotColors.forEachIndexed { index, color -> + val offsetY by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 0f, + animationSpec = infiniteRepeatable( + animation = keyframes { + durationMillis = 1400 + 0f at 0 + -2.4f at 350 + 2.4f at 1050 + 0f at 1400 + }, + repeatMode = RepeatMode.Restart, + initialStartOffset = StartOffset(delays[index]) + ), + label = "offsetY$index" + ) + + Box( + modifier = Modifier + .size(4.dp) + .graphicsLayer { + translationY = offsetY + } + .background( + color = color, + shape = RoundedCornerShape(50) + ) + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt index 70e4d04f..61b2f40f 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt @@ -1,6 +1,5 @@ package com.slax.reader.ui.bookmark.components -import androidx.compose.animation.* import androidx.compose.animation.core.* import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background @@ -16,17 +15,14 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.lerp import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp as lerpDp import androidx.compose.ui.unit.sp -import androidx.compose.ui.draw.drawBehind import com.slax.reader.ui.bookmark.BookmarkDetailViewModel import com.slax.reader.ui.bookmark.states.OutlineDialogStatus import com.slax.reader.utils.i18n @@ -38,9 +34,6 @@ import slax_reader_client.composeapp.generated.resources.ic_outline_dialog_close import slax_reader_client.composeapp.generated.resources.ic_outline_dialog_shrink import androidx.compose.ui.draw.dropShadow import androidx.compose.ui.graphics.shadow.Shadow -import kotlin.math.PI -import kotlin.math.cos -import kotlin.math.sin @Composable fun OutlineDialog() { @@ -223,7 +216,29 @@ fun OutlineDialog() { .offset(x = (-87).dp), contentAlignment = Alignment.BottomCenter ) { - CollapsedOutlineButton(collapsedAlpha) + Box(contentAlignment = Alignment.Center) { + Box( + modifier = Modifier + .size(50.dp) + .offset(y = 10.dp) + .dropShadow( + shape = RoundedCornerShape(25.dp), + shadow = Shadow( + radius = 40.dp, + spread = 0.dp, + color = Color.Black.copy(alpha = 0.16f), + offset = DpOffset(x = 0.dp, y = 10.dp) + ) + ) + ) + + // 隔离阴影动画 + Box(modifier = Modifier.graphicsLayer { alpha = collapsedAlpha }, + contentAlignment = Alignment.Center + ) { + CollapsedOutlineButton() + } + } } } } @@ -430,137 +445,31 @@ private fun ExpandedOutlineDialog() { * 收缩态圆形按钮 */ @Composable -private fun CollapsedOutlineButton(animateAlpha: Float = 1f) { +private fun CollapsedOutlineButton() { val viewModel = koinViewModel() val outlineState by viewModel.outlineDelegate.outlineState.collectAsState() val isLoading = outlineState.isLoading - Box(contentAlignment = Alignment.Center) { - Box( - modifier = Modifier - .size(50.dp) - .offset(y = 10.dp) - .dropShadow( - shape = RoundedCornerShape(25.dp), - shadow = Shadow( - radius = 40.dp, - spread = 0.dp, - color = Color.Black.copy(alpha = 0.16f), - offset = DpOffset(x = 0.dp, y = 10.dp) - ) - ) - ) - - Surface( - onClick = { viewModel.outlineDelegate.expandDialog() }, - modifier = Modifier.size(50.dp) - .graphicsLayer { alpha = animateAlpha }, - color = Color(0xFFFFFFFF), - shape = RoundedCornerShape(25.dp), - border = BorderStroke(1.dp, Color.White) - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - painter = painterResource(Res.drawable.ic_outline_collapsed), - contentDescription = "outline_expand".i18n(), - tint = Color.Unspecified, - modifier = Modifier.size(20.dp) - ) - } - } - - if (isLoading) { - DotLoadingRing(modifier = Modifier.size(27.dp)) - } - } -} - -/** - * dot 旋转加载环 - */ -@Composable -private fun DotLoadingRing(modifier: Modifier = Modifier) { - val infiniteTransition = rememberInfiniteTransition(label = "dotRingRotation") - val rotation by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = 360f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 1500, easing = LinearEasing), - repeatMode = RepeatMode.Restart - ), - label = "rotation" - ) - - val startColor = Color(0xFF16B998) - val endColor = Color(0xFFECF9F6) - val dotCount = 20 - - val dotColors = remember { - List(dotCount) { i -> lerp(startColor, endColor, i.toFloat() / dotCount) } - } - - Box( - modifier = modifier.graphicsLayer { rotationZ = rotation }.drawBehind { - val radius = size.minDimension / 2f - val dotRadius = 1.5.dp.toPx() / 2f - val center = androidx.compose.ui.geometry.Offset(size.width / 2f, size.height / 2f) - - for (i in 0 until dotCount) { - val angle = (2.0 * PI * i / dotCount - PI / 2).toFloat() - val dotCenter = androidx.compose.ui.geometry.Offset( - x = center.x + radius * cos(angle), - y = center.y + radius * sin(angle) - ) - drawCircle( - color = dotColors[i], - radius = dotRadius, - center = dotCenter - ) - } - } - ) -} - -/** - * 加载动画组件 - * 显示三个渐变色横条闪动效果 - */ -@Composable -private fun LoadingAnimation() { - val infiniteTransition = rememberInfiniteTransition(label = "loadingAnimation") - val alpha by infiniteTransition.animateFloat( - initialValue = 0.3f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 800, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse - ), - label = "alphaAnimation" - ) - - Column( - modifier = Modifier - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp) + Surface( + onClick = { viewModel.outlineDelegate.expandDialog() }, + modifier = Modifier.size(50.dp), + color = Color(0xFFFFFFFF), + shape = RoundedCornerShape(25.dp), + border = BorderStroke(1.dp, Color.White) ) { - repeat(3) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(18.dp) - .alpha(alpha) - .background( - brush = Brush.horizontalGradient( - colors = listOf( - Color(0xFFF5F5F3), - Color(0x99F5F5F3), - ) - ), - shape = RoundedCornerShape(4.dp) - ) + Box(contentAlignment = Alignment.Center) { + Icon( + painter = painterResource(Res.drawable.ic_outline_collapsed), + contentDescription = "outline_expand".i18n(), + tint = Color.Unspecified, + modifier = Modifier.size(20.dp) ) } } + + if (isLoading) { + DotLoadingRing(modifier = Modifier.size(27.dp)) + } } /** @@ -589,58 +498,4 @@ private fun ErrorView(error: String) { ) } } -} - -/** - * 圆点加载组件 - */ -@Composable -private fun DotLoadingAnimation() { - val infiniteTransition = rememberInfiniteTransition(label = "dotLoadingAnimation") - - val dotColors = listOf( - Color(0xFF16B998), - Color(0xFFFFC255), - Color(0xFF56CAF2), - Color(0xFFFB8F6C) - ) - - val delays = listOf(0, 250, 500, 750) - - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.height(8.dp) - ) { - dotColors.forEachIndexed { index, color -> - val offsetY by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = 0f, - animationSpec = infiniteRepeatable( - animation = keyframes { - durationMillis = 1400 - 0f at 0 - -2.4f at 350 - 2.4f at 1050 - 0f at 1400 - }, - repeatMode = RepeatMode.Restart, - initialStartOffset = StartOffset(delays[index]) - ), - label = "offsetY$index" - ) - - Box( - modifier = Modifier - .size(4.dp) - .graphicsLayer { - translationY = offsetY - } - .background( - color = color, - shape = RoundedCornerShape(50) - ) - ) - } - } } \ No newline at end of file From e4561d75fe144e0fb88b21384cce1e0bdac5752f Mon Sep 17 00:00:00 2001 From: YaoJunchang Date: Mon, 16 Mar 2026 13:50:56 +0800 Subject: [PATCH 11/31] =?UTF-8?q?=F0=9F=8E=A8=20add=20scroll=20position=20?= =?UTF-8?q?management=20to=20OutlineDialog=20for=20improved=20user=20exper?= =?UTF-8?q?ience?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/slax/reader/ui/bookmark/ViewModel.kt | 3 + .../ui/bookmark/components/OutlineDialog.kt | 56 +++++++++++-------- .../ui/bookmark/states/OutlineDelegate.kt | 9 +++ 3 files changed, 45 insertions(+), 23 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt index cce53996..28830d1c 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt @@ -123,6 +123,9 @@ class BookmarkDetailViewModel( loadSavedPosition(bookmarkId) } + // 后台预加载 Outline(缓存优先,无缓存时流式请求 API) + loadOutline() + refreshContent() } diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt index 61b2f40f..efad1f42 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt @@ -40,15 +40,8 @@ fun OutlineDialog() { println("[watch][UI] recomposition OutlineDialog") val viewModel = koinViewModel() - val bookmarkId by viewModel.bookmarkId.collectAsState() val status by viewModel.outlineDelegate.dialogStatus.collectAsState() - LaunchedEffect(bookmarkId) { - if (bookmarkId != null) { - viewModel.loadOutline() - } - } - if (status == OutlineDialogStatus.NONE) return val transitionState = remember { MutableTransitionState(OutlineDialogStatus.HIDDEN) } @@ -159,9 +152,6 @@ fun OutlineDialog() { } ) { 0f } - // 基于实际 alpha 值控制组合树的存在,alpha=0 时立即移除, - // 避免 Surface(shadowElevation) 的阴影在 graphicsLayer { alpha=0 } 下仍被平台渲染器绘制 - val expandedVisible = expandedAlpha > 0f val collapsedVisible = status == OutlineDialogStatus.COLLAPSED || collapsedAlpha > 0f BoxWithConstraints(modifier = Modifier.fillMaxSize()) { @@ -194,18 +184,21 @@ fun OutlineDialog() { ) } - if (expandedVisible) { - Box( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - alpha = expandedAlpha - translationY = expandedSlideOffset * screenHeightPx - }, - contentAlignment = Alignment.BottomCenter - ) { - ExpandedOutlineDialog() - } + Box( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + alpha = expandedAlpha + // 不可见时移至屏幕外,避免透明状态下仍拦截触摸事件 + translationY = if (expandedAlpha > 0f) { + expandedSlideOffset * screenHeightPx + } else { + screenHeightPx + } + }, + contentAlignment = Alignment.BottomCenter + ) { + ExpandedOutlineDialog() } if (collapsedVisible) { @@ -331,11 +324,28 @@ private fun ExpandedOutlineDialog() { } else -> { + val scrollState = rememberScrollState() + + // 布局完成后恢复滚动位置 + LaunchedEffect(Unit) { + val savedPos = viewModel.outlineDelegate.savedScrollPosition + if (savedPos > 0) { + scrollState.scrollTo(savedPos) + } + } + + // 离开组合树时保存滚动位置 + DisposableEffect(Unit) { + onDispose { + viewModel.outlineDelegate.saveScrollPosition(scrollState.value) + } + } + Box(modifier = Modifier.fillMaxSize()) { Column( modifier = Modifier .fillMaxSize() - .verticalScroll(rememberScrollState()) + .verticalScroll(scrollState) .padding(top = 4.dp) ) { MarkdownRenderer( diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/OutlineDelegate.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/OutlineDelegate.kt index 4ed777d2..1024f78e 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/OutlineDelegate.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/OutlineDelegate.kt @@ -40,6 +40,14 @@ class OutlineDelegate( private val _dialogStatus = MutableStateFlow(OutlineDialogStatus.NONE) val dialogStatus = _dialogStatus.asStateFlow() + // 记住 Outline 弹窗的滚动位置(ViewModel 生命周期内有效,不持久化) + var savedScrollPosition: Int = 0 + private set + + fun saveScrollPosition(position: Int) { + savedScrollPosition = position + } + fun loadOutline(bookmarkId: String) { if (_outlineState.value.isLoading) { return @@ -144,6 +152,7 @@ class OutlineDelegate( fun reset() { _outlineState.value = OutlineState() _dialogStatus.value = OutlineDialogStatus.NONE + savedScrollPosition = 0 } private fun transitionTo(target: OutlineDialogStatus) { From 74f6a8643903f983e4483602aecd0837d57db3ce Mon Sep 17 00:00:00 2001 From: YaoJunchang Date: Mon, 16 Mar 2026 14:20:21 +0800 Subject: [PATCH 12/31] =?UTF-8?q?=F0=9F=8E=A8=20implement=20scroll=20posit?= =?UTF-8?q?ion=20persistence=20for=20OutlineDialog=20to=20enhance=20user?= =?UTF-8?q?=20experience?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reader/data/database/dao/LocalBookmark.kt | 36 +++++++++++++++ .../ui/bookmark/components/OutlineDialog.kt | 20 +++++--- .../ui/bookmark/states/OutlineDelegate.kt | 46 ++++++++++++++++++- 3 files changed, 93 insertions(+), 9 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/data/database/dao/LocalBookmark.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/data/database/dao/LocalBookmark.kt index cfb79e55..f64753a4 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/data/database/dao/LocalBookmark.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/data/database/dao/LocalBookmark.kt @@ -204,4 +204,40 @@ class LocalBookmarkDao( } )?.takeIf { it.isNotEmpty() } } + + suspend fun updateLocalBookmarkOutlineScrollPosition( + bookmarkId: String, + scrollPosition: Int + ) = withContext(Dispatchers.IO) { + database.writeTransaction { tx -> + tx.execute( + """ + INSERT INTO ps_data_local__local_bookmark_info (id, data) + VALUES (?, json_object('outline_scroll_position', ?)) + ON CONFLICT(id) DO UPDATE SET + data = json_set( + ps_data_local__local_bookmark_info.data, + '$.outline_scroll_position', json_extract(excluded.data, '$.outline_scroll_position') + ); + """.trimIndent(), + parameters = listOf(bookmarkId, scrollPosition.toString()) + ) + } + } + + suspend fun getLocalBookmarkOutlineScrollPosition(bookmarkId: String): Int? { + val raw = database.getOptional( + """ + SELECT + json_extract(data, '$.outline_scroll_position') AS outline_scroll_position + FROM ps_data_local__local_bookmark_info + WHERE id = ? + """.trimIndent(), + parameters = listOf(bookmarkId), + mapper = { cursor -> + cursor.getStringOptional("outline_scroll_position") ?: "" + } + ) + return raw?.takeIf { it.isNotEmpty() }?.toIntOrNull() + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt index efad1f42..45c1fd42 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt @@ -34,6 +34,8 @@ import slax_reader_client.composeapp.generated.resources.ic_outline_dialog_close import slax_reader_client.composeapp.generated.resources.ic_outline_dialog_shrink import androidx.compose.ui.draw.dropShadow import androidx.compose.ui.graphics.shadow.Shadow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first @Composable fun OutlineDialog() { @@ -326,19 +328,23 @@ private fun ExpandedOutlineDialog() { else -> { val scrollState = rememberScrollState() - // 布局完成后恢复滚动位置 + // 等待内容布局完成后恢复滚动位置 LaunchedEffect(Unit) { val savedPos = viewModel.outlineDelegate.savedScrollPosition if (savedPos > 0) { - scrollState.scrollTo(savedPos) + snapshotFlow { scrollState.maxValue } + .first { it > 0 } + scrollState.scrollTo(savedPos.coerceAtMost(scrollState.maxValue)) } } - // 离开组合树时保存滚动位置 - DisposableEffect(Unit) { - onDispose { - viewModel.outlineDelegate.saveScrollPosition(scrollState.value) - } + // 滚动过程中持续保存位置(防抖写入 DB) + LaunchedEffect(scrollState) { + snapshotFlow { scrollState.value } + .distinctUntilChanged() + .collect { position -> + viewModel.outlineDelegate.saveScrollPosition(position) + } } Box(modifier = Modifier.fillMaxSize()) { diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/OutlineDelegate.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/OutlineDelegate.kt index 1024f78e..613287af 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/OutlineDelegate.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/OutlineDelegate.kt @@ -8,6 +8,8 @@ import com.slax.reader.utils.outlineEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -34,18 +36,45 @@ class OutlineDelegate( private val apiService: ApiService, private val scope: CoroutineScope ) { + companion object { + private const val SAVE_DEBOUNCE_MS = 2000L + } + private val _outlineState = MutableStateFlow(OutlineState()) val outlineState = _outlineState.asStateFlow() private val _dialogStatus = MutableStateFlow(OutlineDialogStatus.NONE) val dialogStatus = _dialogStatus.asStateFlow() - // 记住 Outline 弹窗的滚动位置(ViewModel 生命周期内有效,不持久化) var savedScrollPosition: Int = 0 private set + private var currentBookmarkId: String? = null + private var currentScrollPosition: Int = -1 + private var saveScrollJob: Job? = null + fun saveScrollPosition(position: Int) { savedScrollPosition = position + currentScrollPosition = position + saveScrollJob?.cancel() + saveScrollJob = scope.launch { + delay(SAVE_DEBOUNCE_MS) + val id = currentBookmarkId ?: return@launch + withContext(Dispatchers.IO) { + localBookmarkDao.updateLocalBookmarkOutlineScrollPosition(id, position) + } + } + } + + fun flushScrollPosition() { + saveScrollJob?.cancel() + val id = currentBookmarkId ?: return + val position = currentScrollPosition + if (position < 0) return + // 使用独立 CoroutineScope,避免 ViewModel scope 取消导致写入丢失 + CoroutineScope(Dispatchers.IO).launch { + localBookmarkDao.updateLocalBookmarkOutlineScrollPosition(id, position) + } } fun loadOutline(bookmarkId: String) { @@ -53,7 +82,17 @@ class OutlineDelegate( return } + currentBookmarkId = bookmarkId + scope.launch { + // 先加载保存的滚动位置,确保在 outline 内容更新前就绑定到 savedScrollPosition + val savedPos = withContext(Dispatchers.IO) { + localBookmarkDao.getLocalBookmarkOutlineScrollPosition(bookmarkId) + } + if (savedPos != null && savedPos > 0) { + savedScrollPosition = savedPos + } + val cacheOutline = withContext(Dispatchers.IO) { localBookmarkDao.getLocalBookmarkOutline(bookmarkId) } @@ -150,12 +189,15 @@ class OutlineDelegate( } fun reset() { + flushScrollPosition() _outlineState.value = OutlineState() _dialogStatus.value = OutlineDialogStatus.NONE savedScrollPosition = 0 + currentScrollPosition = -1 + currentBookmarkId = null } private fun transitionTo(target: OutlineDialogStatus) { _dialogStatus.value = target } -} +} \ No newline at end of file From 01ee4f622b96deef839bde9fc5e042a844a0db57 Mon Sep 17 00:00:00 2001 From: YaoJunchang Date: Mon, 16 Mar 2026 15:27:11 +0800 Subject: [PATCH 13/31] =?UTF-8?q?=F0=9F=8E=A8=20refactor=20bookmark=20dele?= =?UTF-8?q?tion=20flow=20and=20enhance=20loading=20animations=20for=20impr?= =?UTF-8?q?oved=20UI=20experience?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/slax/reader/ui/bookmark/ViewModel.kt | 37 +++++++++++---- .../bookmark/components/BottomToolbarSheet.kt | 14 ++---- .../bookmark/components/FloatingActionBar.kt | 11 +++-- .../ui/bookmark/components/ImageViewer.kt | 47 ++++++++++++------- .../{Loading.kt => LoadingAnimation.kt} | 22 +++++---- .../ui/bookmark/components/OutlineDialog.kt | 6 +-- .../ui/bookmark/states/BookmarkDelegate.kt | 14 ++---- .../ui/bookmark/states/OutlineDelegate.kt | 15 +++++- .../slax/reader/ui/inbox/InboxListScreen.kt | 16 +++---- 9 files changed, 106 insertions(+), 76 deletions(-) rename composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/{Loading.kt => LoadingAnimation.kt} (90%) diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt index 28830d1c..91fcd8ed 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt @@ -82,6 +82,9 @@ class BookmarkDetailViewModel( private val _effects = MutableSharedFlow(extraBufferCapacity = 8) val effects: SharedFlow = _effects.asSharedFlow() + private val _deleteConfirmVisible = MutableStateFlow(false) + val deleteConfirmVisible: StateFlow = _deleteConfirmVisible.asStateFlow() + private val _contentState = MutableStateFlow(BookmarkContentState(isLoading = false)) val contentState = _contentState.asStateFlow() @@ -177,6 +180,30 @@ class BookmarkDetailViewModel( } } + fun requestDeleteBookmark() { + _deleteConfirmVisible.value = true + } + + fun dismissDeleteConfirmation() { + _deleteConfirmVisible.value = false + } + + fun confirmDeleteBookmark() { + viewModelScope.launch { + runCatching { bookmarkDelegate.deleteBookmark() } + .onSuccess { + bookmarkEvent.action("delete").send() + _deleteConfirmVisible.value = false + overlayDelegate.dismissOverlay(BookmarkOverlay.Toolbar) + requestNavigateBack() + } + .onFailure { + bookmarkEvent.action("delete_failed").send() + _deleteConfirmVisible.value = false + } + } + } + fun onToolbarIconClick(pageId: String) { val current = bookmarkDelegate.bookmarkDetailState.value @@ -197,17 +224,7 @@ class BookmarkDetailViewModel( outlineDelegate.showDialog() } } - "feedback" -> overlayDelegate.showOverlay(BookmarkOverlay.FeedbackRequired) - "delete" -> bookmarkDelegate.onDeleteBookmark( - onSuccess = { - bookmarkEvent.action("delete").send() - requestNavigateBack() - }, - onFailure = { - bookmarkEvent.action("delete_failed").send() - } - ) } overlayDelegate.dismissOverlay(BookmarkOverlay.Toolbar) diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/BottomToolbarSheet.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/BottomToolbarSheet.kt index e67716ef..721d1d8f 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/BottomToolbarSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/BottomToolbarSheet.kt @@ -41,10 +41,9 @@ data class ToolbarIcon( */ @Composable fun BottomToolbarSheet() { - println("[watch][UI] recomposition BottomToolbarSheet") val viewModel = koinViewModel() val detailState by viewModel.bookmarkDelegate.bookmarkDetailState.collectAsState() - var showDeleteConfirm by remember { mutableStateOf(false) } + val showDeleteConfirm by viewModel.deleteConfirmVisible.collectAsState() val toolbarPages = remember(detailState.isStarred, detailState.isArchived) { listOf( @@ -126,7 +125,7 @@ fun BottomToolbarSheet() { pages = toolbarPages, onIconClick = { pageId, iconIndex -> if (pageId == "delete") { - showDeleteConfirm = true + viewModel.requestDeleteBookmark() } else { viewModel.onToolbarIconClick(pageId) dismiss() @@ -140,20 +139,17 @@ fun BottomToolbarSheet() { if (showDeleteConfirm) { AlertDialog( - onDismissRequest = { showDeleteConfirm = false }, + onDismissRequest = { viewModel.dismissDeleteConfirmation() }, containerColor = Color.White, title = { Text(text = "bookmark_delete_confirm_title".i18n()) }, text = { Text(text = "bookmark_delete_confirm_message".i18n()) }, confirmButton = { - TextButton(onClick = { - showDeleteConfirm = false - viewModel.onToolbarIconClick("delete") - }) { + TextButton(onClick = { viewModel.confirmDeleteBookmark() }) { Text(text = "btn_confirm".i18n(), color = Color(0xFFF45454)) } }, dismissButton = { - TextButton(onClick = { showDeleteConfirm = false }) { + TextButton(onClick = { viewModel.dismissDeleteConfirmation() }) { Text(text = "btn_cancel".i18n()) } } diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/FloatingActionBar.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/FloatingActionBar.kt index 79bc3d69..e29be2ac 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/FloatingActionBar.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/FloatingActionBar.kt @@ -24,7 +24,10 @@ import androidx.compose.ui.unit.dp import com.slax.reader.ui.bookmark.BookmarkDetailViewModel import com.slax.reader.ui.bookmark.LocalToolbarVisible import com.slax.reader.ui.bookmark.states.BookmarkOverlay +import com.slax.reader.ui.bookmark.states.OutlineDialogStatus import com.slax.reader.utils.bookmarkEvent +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import org.jetbrains.compose.resources.painterResource import org.koin.compose.viewmodel.koinViewModel import slax_reader_client.composeapp.generated.resources.* @@ -33,7 +36,6 @@ import slax_reader_client.composeapp.generated.resources.* fun FloatingActionBar( modifier: Modifier = Modifier, ) { - println("[watch][UI] recomposition FloatingActionBar") val viewModel = koinViewModel() val visible by LocalToolbarVisible.current @@ -42,8 +44,11 @@ fun FloatingActionBar( val isArchived by remember { derivedStateOf { detailState.isArchived } } // 观察大纲收缩状态,用于整体居中动画 - val outlineStatus by viewModel.outlineDelegate.dialogStatus.collectAsState() - val isOutlineCollapsed by remember { derivedStateOf { outlineStatus == com.slax.reader.ui.bookmark.states.OutlineDialogStatus.COLLAPSED } } + // 使用 map + distinctUntilChanged 避免每次 outlineStatus 变化都触发重组 + val isOutlineCollapsed by viewModel.outlineDelegate.dialogStatus + .map { it == OutlineDialogStatus.COLLAPSED } + .distinctUntilChanged() + .collectAsState(initial = false) val density = LocalDensity.current val hiddenOffsetPx = remember(density) { with(density) { 150.dp.toPx() } } diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/ImageViewer.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/ImageViewer.kt index 8c8094f3..27edb25d 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/ImageViewer.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/ImageViewer.kt @@ -442,24 +442,35 @@ private fun ZoomableImagePage( // 图片加载中时显示进度圈 if (asyncImageState.loadState is LoadState.Started) { - val progress = asyncImageState.progress?.decimalProgress - if (progress != null && progress > 0f) { - // 确定性进度圈(显示具体进度) - CircularProgressIndicator( - progress = { progress }, - modifier = Modifier.align(Alignment.Center), - color = Color.White, - trackColor = Color.White.copy(alpha = 0.3f), - strokeWidth = 3.dp - ) - } else { - // 不确定性进度圈(无法获取进度时) - CircularProgressIndicator( - modifier = Modifier.align(Alignment.Center), - color = Color.White, - strokeWidth = 3.dp - ) - } + ImageLoadingIndicator( + progress = asyncImageState.progress?.decimalProgress, + modifier = Modifier.align(Alignment.Center) + ) } } +} + +/** + * 图片加载进度指示器,有进度时显示确定性圆形进度条,否则显示不确定性进度条 + */ +@Composable +private fun ImageLoadingIndicator( + progress: Float?, + modifier: Modifier = Modifier +) { + if (progress != null && progress > 0f) { + CircularProgressIndicator( + progress = { progress }, + modifier = modifier, + color = Color.White, + trackColor = Color.White.copy(alpha = 0.3f), + strokeWidth = 3.dp + ) + } else { + CircularProgressIndicator( + modifier = modifier, + color = Color.White, + strokeWidth = 3.dp + ) + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/Loading.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/LoadingAnimation.kt similarity index 90% rename from composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/Loading.kt rename to composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/LoadingAnimation.kt index 8828fc49..47bb508c 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/Loading.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/LoadingAnimation.kt @@ -36,12 +36,12 @@ import kotlin.math.sin /** - * 加载动画组件 - * 显示三个渐变色横条闪动效果 + * 骨架屏加载动画 - 用于 Outline 内容初始加载状态 + * 显示三个渐变色横条持续闪动,占位正文区域 */ @Composable -fun LoadingAnimation() { - val infiniteTransition = rememberInfiniteTransition(label = "loadingAnimation") +fun SkeletonLoadingAnimation() { + val infiniteTransition = rememberInfiniteTransition(label = "skeletonLoadingAnimation") val alpha by infiniteTransition.animateFloat( initialValue = 0.3f, targetValue = 1f, @@ -78,11 +78,12 @@ fun LoadingAnimation() { } /** - * dot 旋转加载环 + * 旋转点环加载动画 - 用于收缩态按钮(CollapsedOutlineButton)的加载状态 + * 显示 20 个渐变色圆点组成的旋转环形 */ @Composable -fun DotLoadingRing(modifier: Modifier = Modifier) { - val infiniteTransition = rememberInfiniteTransition(label = "dotRingRotation") +fun DotsRingLoadingAnimation(modifier: Modifier = Modifier) { + val infiniteTransition = rememberInfiniteTransition(label = "dotsRingRotation") val rotation by infiniteTransition.animateFloat( initialValue = 0f, targetValue = 360f, @@ -124,11 +125,12 @@ fun DotLoadingRing(modifier: Modifier = Modifier) { } /** - * 圆点加载组件 + * 四色跳动点加载动画 - 用于 Outline 流式内容加载过程中的底部提示 + * 显示 4 个彩色圆点依次上下弹跳 */ @Composable -fun DotLoadingAnimation() { - val infiniteTransition = rememberInfiniteTransition(label = "dotLoadingAnimation") +fun DotsLineLoadingAnimation() { + val infiniteTransition = rememberInfiniteTransition(label = "dotsLineLoadingAnimation") val dotColors = listOf( Color(0xFF16B998), diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt index 45c1fd42..2708563c 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt @@ -318,7 +318,7 @@ private fun ExpandedOutlineDialog() { ) { when { outlineState.isLoading && outlineState.isPending -> { - LoadingAnimation() + SkeletonLoadingAnimation() } outlineState.error != null -> { @@ -369,7 +369,7 @@ private fun ExpandedOutlineDialog() { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { - DotLoadingAnimation() + DotsLineLoadingAnimation() } } @@ -484,7 +484,7 @@ private fun CollapsedOutlineButton() { } if (isLoading) { - DotLoadingRing(modifier = Modifier.size(27.dp)) + DotsRingLoadingAnimation(modifier = Modifier.size(27.dp)) } } diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/BookmarkDelegate.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/BookmarkDelegate.kt index cbc132e1..0b26e96c 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/BookmarkDelegate.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/BookmarkDelegate.kt @@ -98,17 +98,9 @@ class BookmarkDelegate( } } - fun onDeleteBookmark(onSuccess: () -> Unit, onFailure: () -> Unit) { - scope.launch { - runCatching { - bookmarkIdFlow.value?.let { id -> - bookmarkDao.deleteBookmark(id) - } - }.onSuccess { - onSuccess() - }.onFailure { - onFailure() - } + suspend fun deleteBookmark(): Unit = withContext(Dispatchers.IO) { + bookmarkIdFlow.value?.let { id -> + bookmarkDao.deleteBookmark(id) } } diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/OutlineDelegate.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/OutlineDelegate.kt index 613287af..f834cf64 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/OutlineDelegate.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/OutlineDelegate.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -71,8 +72,8 @@ class OutlineDelegate( val id = currentBookmarkId ?: return val position = currentScrollPosition if (position < 0) return - // 使用独立 CoroutineScope,避免 ViewModel scope 取消导致写入丢失 - CoroutineScope(Dispatchers.IO).launch { + // 使用 NonCancellable 确保 ViewModel scope 取消(页面关闭)时写入操作仍能完成 + scope.launch(NonCancellable + Dispatchers.IO) { localBookmarkDao.updateLocalBookmarkOutlineScrollPosition(id, position) } } @@ -159,6 +160,16 @@ class OutlineDelegate( } } + /** + * 对话框状态转换矩阵: + * + * NONE ──showDialog──────► EXPANDED + * HIDDEN ──expandDialog────► EXPANDED + * EXPANDED──collapseDialog──► COLLAPSED + * COLLAPSED─expandDialog────► EXPANDED + * EXPANDED/COLLAPSED──hideDialog──► HIDDEN + * HIDDEN ──reset───────────► NONE + */ fun showDialog() { transitionTo(OutlineDialogStatus.EXPANDED) } diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/InboxListScreen.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/InboxListScreen.kt index f466e9b6..69d23026 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/InboxListScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/InboxListScreen.kt @@ -161,16 +161,12 @@ private fun NavigationBar( verticalAlignment = Alignment.CenterVertically, ) { - Box( - modifier = Modifier - ) { - Image( - painter = painterResource(Res.drawable.ic_inbox_tab), - contentDescription = "Menu", - modifier = Modifier.padding(start = 24.dp).padding(vertical = 10.dp).size(24.dp, 24.dp), - contentScale = ContentScale.Fit - ) - } + Image( + painter = painterResource(Res.drawable.ic_inbox_tab), + contentDescription = "Menu", + modifier = Modifier.padding(start = 24.dp).padding(vertical = 10.dp).size(24.dp, 24.dp), + contentScale = ContentScale.Fit + ) UserAvatar() } From b34a01591e7572e1ee69bdbcb8270724e4e56502 Mon Sep 17 00:00:00 2001 From: YaoJunchang Date: Mon, 16 Mar 2026 17:06:58 +0800 Subject: [PATCH 14/31] =?UTF-8?q?=F0=9F=8E=A8=20enhance=20scroll=20positio?= =?UTF-8?q?n=20management=20in=20OutlineDialog=20with=20debounce=20for=20i?= =?UTF-8?q?mproved=20performance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../slax/reader/ui/bookmark/components/OutlineDialog.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt index 2708563c..638e3537 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt @@ -34,6 +34,7 @@ import slax_reader_client.composeapp.generated.resources.ic_outline_dialog_close import slax_reader_client.composeapp.generated.resources.ic_outline_dialog_shrink import androidx.compose.ui.draw.dropShadow import androidx.compose.ui.graphics.shadow.Shadow +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first @@ -328,20 +329,20 @@ private fun ExpandedOutlineDialog() { else -> { val scrollState = rememberScrollState() - // 等待内容布局完成后恢复滚动位置 LaunchedEffect(Unit) { + // 恢复滚动位置 val savedPos = viewModel.outlineDelegate.savedScrollPosition if (savedPos > 0) { snapshotFlow { scrollState.maxValue } .first { it > 0 } scrollState.scrollTo(savedPos.coerceAtMost(scrollState.maxValue)) } - } - // 滚动过程中持续保存位置(防抖写入 DB) - LaunchedEffect(scrollState) { + // 恢复完成后再开始监听,顺序执行避免竞态 + @OptIn(kotlinx.coroutines.FlowPreview::class) snapshotFlow { scrollState.value } .distinctUntilChanged() + .debounce(500) .collect { position -> viewModel.outlineDelegate.saveScrollPosition(position) } From 6c92133394e58db6734f5b1cb582f25ff0e29a0f Mon Sep 17 00:00:00 2001 From: YaoJunchang Date: Mon, 16 Mar 2026 17:11:26 +0800 Subject: [PATCH 15/31] =?UTF-8?q?=F0=9F=8E=A8=20rename=20scroll=20position?= =?UTF-8?q?=20references=20to=20read=20position=20in=20LocalBookmark=20for?= =?UTF-8?q?=20clarity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/slax/reader/data/database/dao/LocalBookmark.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/data/database/dao/LocalBookmark.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/data/database/dao/LocalBookmark.kt index f64753a4..4f91822e 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/data/database/dao/LocalBookmark.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/data/database/dao/LocalBookmark.kt @@ -213,11 +213,11 @@ class LocalBookmarkDao( tx.execute( """ INSERT INTO ps_data_local__local_bookmark_info (id, data) - VALUES (?, json_object('outline_scroll_position', ?)) + VALUES (?, json_object('outline_read_position', ?)) ON CONFLICT(id) DO UPDATE SET data = json_set( ps_data_local__local_bookmark_info.data, - '$.outline_scroll_position', json_extract(excluded.data, '$.outline_scroll_position') + '$.outline_read_position', json_extract(excluded.data, '$.outline_read_position') ); """.trimIndent(), parameters = listOf(bookmarkId, scrollPosition.toString()) @@ -229,13 +229,13 @@ class LocalBookmarkDao( val raw = database.getOptional( """ SELECT - json_extract(data, '$.outline_scroll_position') AS outline_scroll_position + json_extract(data, '$.outline_read_position') AS outline_read_position FROM ps_data_local__local_bookmark_info WHERE id = ? """.trimIndent(), parameters = listOf(bookmarkId), mapper = { cursor -> - cursor.getStringOptional("outline_scroll_position") ?: "" + cursor.getStringOptional("outline_read_position") ?: "" } ) return raw?.takeIf { it.isNotEmpty() }?.toIntOrNull() From 4ffdac7d08d00df3c2b44948a052e9c7905efe54 Mon Sep 17 00:00:00 2001 From: YaoJunchang Date: Tue, 17 Mar 2026 09:00:21 +0800 Subject: [PATCH 16/31] =?UTF-8?q?=F0=9F=8E=A8=20simplify=20loading=20anima?= =?UTF-8?q?tion=20comments=20and=20remove=20unnecessary=20code=20for=20cla?= =?UTF-8?q?rity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/slax/reader/ui/bookmark/ViewModel.kt | 2 -- .../ui/bookmark/components/FloatingActionBar.kt | 6 +----- .../ui/bookmark/components/LoadingAnimation.kt | 9 +++------ .../ui/bookmark/components/OutlineDialog.kt | 5 ----- .../reader/ui/bookmark/states/OutlineDelegate.kt | 15 ++------------- 5 files changed, 6 insertions(+), 31 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt index 91fcd8ed..6fc764d2 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt @@ -126,9 +126,7 @@ class BookmarkDetailViewModel( loadSavedPosition(bookmarkId) } - // 后台预加载 Outline(缓存优先,无缓存时流式请求 API) loadOutline() - refreshContent() } diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/FloatingActionBar.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/FloatingActionBar.kt index e29be2ac..418e790b 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/FloatingActionBar.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/FloatingActionBar.kt @@ -43,8 +43,7 @@ fun FloatingActionBar( val isStarred by remember { derivedStateOf { detailState.isStarred } } val isArchived by remember { derivedStateOf { detailState.isArchived } } - // 观察大纲收缩状态,用于整体居中动画 - // 使用 map + distinctUntilChanged 避免每次 outlineStatus 变化都触发重组 + // Outline收缩状态,使用 map + distinctUntilChanged 避免每次 outlineStatus 变化都触发重组 val isOutlineCollapsed by viewModel.outlineDelegate.dialogStatus .map { it == OutlineDialogStatus.COLLAPSED } .distinctUntilChanged() @@ -54,9 +53,6 @@ fun FloatingActionBar( val hiddenOffsetPx = remember(density) { with(density) { 150.dp.toPx() } } val translationY = remember { Animatable(if (visible) 0f else hiddenOffsetPx) } - // 水平偏移动画:收缩态时向右偏移 31dp,与 CollapsedOutlineButton 整体居中 - // 计算依据:组合总宽 224dp = Button(50) + Gap(12) + FAB(162) - // FAB 需向右偏移 (224/2 - 162/2) = 31dp val collapsedOffsetPx = remember(density) { with(density) { 31.dp.toPx() } } val translationX = remember { Animatable(0f) } diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/LoadingAnimation.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/LoadingAnimation.kt index 47bb508c..4ffaa8e6 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/LoadingAnimation.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/LoadingAnimation.kt @@ -36,8 +36,7 @@ import kotlin.math.sin /** - * 骨架屏加载动画 - 用于 Outline 内容初始加载状态 - * 显示三个渐变色横条持续闪动,占位正文区域 + * 骨架屏加载动画 */ @Composable fun SkeletonLoadingAnimation() { @@ -78,8 +77,7 @@ fun SkeletonLoadingAnimation() { } /** - * 旋转点环加载动画 - 用于收缩态按钮(CollapsedOutlineButton)的加载状态 - * 显示 20 个渐变色圆点组成的旋转环形 + * 旋转点环加载动画 */ @Composable fun DotsRingLoadingAnimation(modifier: Modifier = Modifier) { @@ -125,8 +123,7 @@ fun DotsRingLoadingAnimation(modifier: Modifier = Modifier) { } /** - * 四色跳动点加载动画 - 用于 Outline 流式内容加载过程中的底部提示 - * 显示 4 个彩色圆点依次上下弹跳 + * 跳动点加载动画 */ @Composable fun DotsLineLoadingAnimation() { diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt index 638e3537..bcfaa7f4 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt @@ -192,7 +192,6 @@ fun OutlineDialog() { .fillMaxSize() .graphicsLayer { alpha = expandedAlpha - // 不可见时移至屏幕外,避免透明状态下仍拦截触摸事件 translationY = if (expandedAlpha > 0f) { expandedSlideOffset * screenHeightPx } else { @@ -242,9 +241,6 @@ fun OutlineDialog() { /** * 变形遮罩层:在 EXPANDED ↔ COLLAPSED 过渡期间,以白色形状模拟容器从矩形变形为圆形的效果。 - * - * morphProgress:0f = 收缩态(50dp 圆形,位于按钮位置),1f = 展开态(全宽矩形,位于弹窗位置) - * morphAlpha:由 keyframes 控制,仅在过渡期间可见,静止时始终为 0f。 */ @Composable private fun MorphOverlay( @@ -338,7 +334,6 @@ private fun ExpandedOutlineDialog() { scrollState.scrollTo(savedPos.coerceAtMost(scrollState.maxValue)) } - // 恢复完成后再开始监听,顺序执行避免竞态 @OptIn(kotlinx.coroutines.FlowPreview::class) snapshotFlow { scrollState.value } .distinctUntilChanged() diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/OutlineDelegate.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/OutlineDelegate.kt index f834cf64..89ec6751 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/OutlineDelegate.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/OutlineDelegate.kt @@ -72,8 +72,7 @@ class OutlineDelegate( val id = currentBookmarkId ?: return val position = currentScrollPosition if (position < 0) return - // 使用 NonCancellable 确保 ViewModel scope 取消(页面关闭)时写入操作仍能完成 - scope.launch(NonCancellable + Dispatchers.IO) { + scope.launch(Dispatchers.IO) { localBookmarkDao.updateLocalBookmarkOutlineScrollPosition(id, position) } } @@ -86,10 +85,10 @@ class OutlineDelegate( currentBookmarkId = bookmarkId scope.launch { - // 先加载保存的滚动位置,确保在 outline 内容更新前就绑定到 savedScrollPosition val savedPos = withContext(Dispatchers.IO) { localBookmarkDao.getLocalBookmarkOutlineScrollPosition(bookmarkId) } + if (savedPos != null && savedPos > 0) { savedScrollPosition = savedPos } @@ -160,16 +159,6 @@ class OutlineDelegate( } } - /** - * 对话框状态转换矩阵: - * - * NONE ──showDialog──────► EXPANDED - * HIDDEN ──expandDialog────► EXPANDED - * EXPANDED──collapseDialog──► COLLAPSED - * COLLAPSED─expandDialog────► EXPANDED - * EXPANDED/COLLAPSED──hideDialog──► HIDDEN - * HIDDEN ──reset───────────► NONE - */ fun showDialog() { transitionTo(OutlineDialogStatus.EXPANDED) } From 99b9906b8b67b8a0758d66175ec07b8a7f3c144c Mon Sep 17 00:00:00 2001 From: YaoJunchang Date: Tue, 17 Mar 2026 09:10:55 +0800 Subject: [PATCH 17/31] =?UTF-8?q?=F0=9F=8E=A8=20refactor=20animation=20han?= =?UTF-8?q?dling=20in=20OutlineDialog=20for=20improved=20clarity=20and=20p?= =?UTF-8?q?erformance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/bookmark/components/OutlineDialog.kt | 236 +++++++++--------- 1 file changed, 120 insertions(+), 116 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt index bcfaa7f4..367f12a3 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.unit.lerp as lerpDp import androidx.compose.ui.unit.sp import com.slax.reader.ui.bookmark.BookmarkDetailViewModel import com.slax.reader.ui.bookmark.states.OutlineDialogStatus +import com.slax.reader.ui.bookmark.states.OutlineDialogStatus.* import com.slax.reader.utils.i18n import org.jetbrains.compose.resources.painterResource import org.koin.compose.viewmodel.koinViewModel @@ -45,117 +46,14 @@ fun OutlineDialog() { val status by viewModel.outlineDelegate.dialogStatus.collectAsState() - if (status == OutlineDialogStatus.NONE) return + if (status == NONE) return - val transitionState = remember { MutableTransitionState(OutlineDialogStatus.HIDDEN) } + val transitionState = remember { MutableTransitionState(HIDDEN) } transitionState.targetState = status val transition = rememberTransition(transitionState, label = "outlineTransition") - // === 背景遮罩透明度 === - val bgAlpha by transition.animateFloat( - label = "bgAlpha", - transitionSpec = { tween(300) } - ) { state -> - if (state == OutlineDialogStatus.EXPANDED) 0.5f else 0f - } - - // === 展开弹窗透明度(方向感知:EXPANDED↔HIDDEN 标准淡入淡出,EXPANDED↔COLLAPSED 快速配合变形)=== - val expandedAlpha by transition.animateFloat( - label = "expandedAlpha", - transitionSpec = { - when { - OutlineDialogStatus.HIDDEN isTransitioningTo OutlineDialogStatus.EXPANDED -> - tween(250) - OutlineDialogStatus.COLLAPSED isTransitioningTo OutlineDialogStatus.EXPANDED -> - tween(180, delayMillis = 380) - OutlineDialogStatus.EXPANDED isTransitioningTo OutlineDialogStatus.COLLAPSED -> - tween(120) - OutlineDialogStatus.EXPANDED isTransitioningTo OutlineDialogStatus.HIDDEN -> - tween(200) - else -> snap() - } - } - ) { state -> if (state == OutlineDialogStatus.EXPANDED) 1f else 0f } - - // === 展开弹窗滑动偏移(仅 HIDDEN↔EXPANDED 方向有效,其他方向 snap 到 0)=== - val expandedSlideOffset by transition.animateFloat( - label = "expandedSlideOffset", - transitionSpec = { - when { - OutlineDialogStatus.HIDDEN isTransitioningTo OutlineDialogStatus.EXPANDED -> - tween(300, easing = FastOutSlowInEasing) - OutlineDialogStatus.EXPANDED isTransitioningTo OutlineDialogStatus.HIDDEN -> - tween(250, easing = FastOutSlowInEasing) - else -> snap() - } - } - ) { state -> - when (state) { - OutlineDialogStatus.EXPANDED -> 0f - OutlineDialogStatus.HIDDEN -> 1f - else -> 0f - } - } - - // === 收缩按钮透明度(方向感知:EXPANDED↔COLLAPSED 时配合变形时序,其他方向标准淡入淡出)=== - val collapsedAlpha by transition.animateFloat( - label = "collapsedAlpha", - transitionSpec = { - when { - OutlineDialogStatus.EXPANDED isTransitioningTo OutlineDialogStatus.COLLAPSED -> - tween(150, delayMillis = 430) - OutlineDialogStatus.COLLAPSED isTransitioningTo OutlineDialogStatus.EXPANDED -> - tween(100) - OutlineDialogStatus.HIDDEN isTransitioningTo OutlineDialogStatus.COLLAPSED -> - tween(200) - OutlineDialogStatus.COLLAPSED isTransitioningTo OutlineDialogStatus.HIDDEN -> - tween(200) - else -> snap() - } - } - ) { state -> if (state == OutlineDialogStatus.COLLAPSED) 1f else 0f } - - // === 变形进度:0f = 收缩圆形位置,1f = 展开矩形位置(仅 EXPANDED↔COLLAPSED 时动画)=== - val morphProgress by transition.animateFloat( - label = "morphProgress", - transitionSpec = { - when { - OutlineDialogStatus.EXPANDED isTransitioningTo OutlineDialogStatus.COLLAPSED -> - tween(380, delayMillis = 100, easing = FastOutSlowInEasing) - OutlineDialogStatus.COLLAPSED isTransitioningTo OutlineDialogStatus.EXPANDED -> - tween(380, delayMillis = 80, easing = FastOutSlowInEasing) - else -> snap() - } - } - ) { state -> if (state == OutlineDialogStatus.EXPANDED) 1f else 0f } - - // === 变形遮罩透明度(keyframes 精确控制:仅在 EXPANDED↔COLLAPSED 过渡期间可见)=== - val morphAlpha by transition.animateFloat( - label = "morphAlpha", - transitionSpec = { - when { - OutlineDialogStatus.EXPANDED isTransitioningTo OutlineDialogStatus.COLLAPSED -> - keyframes { - durationMillis = 580 - 0f at 0 using LinearEasing - 1f at 0 using LinearEasing - 1f at 480 using LinearEasing - 0f at 580 - } - OutlineDialogStatus.COLLAPSED isTransitioningTo OutlineDialogStatus.EXPANDED -> - keyframes { - durationMillis = 560 - 0f at 0 using LinearEasing - 1f at 60 using LinearEasing - 1f at 460 using LinearEasing - 0f at 560 - } - else -> snap() - } - } - ) { 0f } - - val collapsedVisible = status == OutlineDialogStatus.COLLAPSED || collapsedAlpha > 0f + val anim = transition.outlineAnimations() + val collapsedVisible = status == COLLAPSED || anim.collapsedAlpha > 0f BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val screenWidth = maxWidth @@ -165,11 +63,11 @@ fun OutlineDialog() { val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val dialogHeight = screenHeight - statusBarHeight - 36.dp - if (bgAlpha > 0f) { + if (anim.bgAlpha > 0f) { Box( modifier = Modifier .fillMaxSize() - .background(Color.Black.copy(alpha = bgAlpha)) + .background(Color.Black.copy(alpha = anim.bgAlpha)) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, @@ -178,10 +76,10 @@ fun OutlineDialog() { ) } - if (morphAlpha > 0f) { + if (anim.morphAlpha > 0f) { MorphOverlay( - morphAlpha = morphAlpha, - morphProgress = morphProgress, + morphAlpha = anim.morphAlpha, + morphProgress = anim.morphProgress, screenWidth = screenWidth, dialogHeight = dialogHeight ) @@ -191,9 +89,9 @@ fun OutlineDialog() { modifier = Modifier .fillMaxSize() .graphicsLayer { - alpha = expandedAlpha - translationY = if (expandedAlpha > 0f) { - expandedSlideOffset * screenHeightPx + alpha = anim.expandedAlpha + translationY = if (anim.expandedAlpha > 0f) { + anim.expandedSlideOffset * screenHeightPx } else { screenHeightPx } @@ -228,7 +126,7 @@ fun OutlineDialog() { ) // 隔离阴影动画 - Box(modifier = Modifier.graphicsLayer { alpha = collapsedAlpha }, + Box(modifier = Modifier.graphicsLayer { alpha = anim.collapsedAlpha }, contentAlignment = Alignment.Center ) { CollapsedOutlineButton() @@ -510,4 +408,110 @@ private fun ErrorView(error: String) { ) } } +} + +// Outline 展开收起最小化 过渡动画定义 + +private data class OutlineAnimations( + val bgAlpha: Float, + val expandedAlpha: Float, + val expandedSlideOffset: Float, + val collapsedAlpha: Float, + val morphProgress: Float, + val morphAlpha: Float, +) + +/** + * 根据过渡方向匹配动画规格,未匹配时回退 snap() + */ +private fun Transition.Segment.specFor( + vararg pairs: Pair, FiniteAnimationSpec> +): FiniteAnimationSpec { + for ((transition, spec) in pairs) { + if (transition.first isTransitioningTo transition.second) return spec + } + return snap() +} + +@Composable +private fun Transition.outlineAnimations(): OutlineAnimations { + val bgAlpha by animateFloat( + label = "bgAlpha", + transitionSpec = { tween(300) } + ) { if (it == EXPANDED) 0.5f else 0f } + + val expandedAlpha by animateFloat( + label = "expandedAlpha", + transitionSpec = { + specFor( + (HIDDEN to EXPANDED) to tween(250), + (COLLAPSED to EXPANDED) to tween(180, delayMillis = 380), + (EXPANDED to COLLAPSED) to tween(120), + (EXPANDED to HIDDEN) to tween(200), + ) + } + ) { if (it == EXPANDED) 1f else 0f } + + val expandedSlideOffset by animateFloat( + label = "expandedSlideOffset", + transitionSpec = { + specFor( + (HIDDEN to EXPANDED) to tween(300, easing = FastOutSlowInEasing), + (EXPANDED to HIDDEN) to tween(250, easing = FastOutSlowInEasing), + ) + } + ) { if (it == EXPANDED) 0f else if (it == HIDDEN) 1f else 0f } + + val collapsedAlpha by animateFloat( + label = "collapsedAlpha", + transitionSpec = { + specFor( + (EXPANDED to COLLAPSED) to tween(150, delayMillis = 430), + (COLLAPSED to EXPANDED) to tween(100), + (HIDDEN to COLLAPSED) to tween(200), + (COLLAPSED to HIDDEN) to tween(200), + ) + } + ) { if (it == COLLAPSED) 1f else 0f } + + val morphProgress by animateFloat( + label = "morphProgress", + transitionSpec = { + specFor( + (EXPANDED to COLLAPSED) to tween(380, delayMillis = 100, easing = FastOutSlowInEasing), + (COLLAPSED to EXPANDED) to tween(380, delayMillis = 80, easing = FastOutSlowInEasing), + ) + } + ) { if (it == EXPANDED) 1f else 0f } + + val morphAlpha by animateFloat( + label = "morphAlpha", + transitionSpec = { + specFor( + (EXPANDED to COLLAPSED) to keyframes { + durationMillis = 580 + 0f at 0 using LinearEasing + 1f at 0 using LinearEasing + 1f at 480 using LinearEasing + 0f at 580 + }, + (COLLAPSED to EXPANDED) to keyframes { + durationMillis = 560 + 0f at 0 using LinearEasing + 1f at 60 using LinearEasing + 1f at 460 using LinearEasing + 0f at 560 + }, + ) + } + ) { 0f } + + return OutlineAnimations( + bgAlpha = bgAlpha, + expandedAlpha = expandedAlpha, + expandedSlideOffset = expandedSlideOffset, + collapsedAlpha = collapsedAlpha, + morphProgress = morphProgress, + morphAlpha = morphAlpha, + ) } \ No newline at end of file From 4af53d5948097a66402365c00f753ac251eb2201 Mon Sep 17 00:00:00 2001 From: YaoJunchang Date: Tue, 17 Mar 2026 09:13:21 +0800 Subject: [PATCH 18/31] =?UTF-8?q?=F0=9F=8E=A8=20simplify=20image=20loading?= =?UTF-8?q?=20indicator=20comments=20for=20improved=20clarity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/slax/reader/ui/bookmark/components/ImageViewer.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/ImageViewer.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/ImageViewer.kt index 27edb25d..85e341a0 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/ImageViewer.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/ImageViewer.kt @@ -451,7 +451,7 @@ private fun ZoomableImagePage( } /** - * 图片加载进度指示器,有进度时显示确定性圆形进度条,否则显示不确定性进度条 + * 图片加载进度指示器 */ @Composable private fun ImageLoadingIndicator( @@ -467,6 +467,7 @@ private fun ImageLoadingIndicator( strokeWidth = 3.dp ) } else { + // 若获取不到进度则显示转圈 CircularProgressIndicator( modifier = modifier, color = Color.White, From 73b3957019fcc015962efef13777a94f5d33e3f8 Mon Sep 17 00:00:00 2001 From: YaoJunchang Date: Tue, 17 Mar 2026 09:31:03 +0800 Subject: [PATCH 19/31] =?UTF-8?q?=F0=9F=8E=A8=20add=20toolbar=20visibility?= =?UTF-8?q?=20animation=20to=20OutlineDialog=20for=20improved=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/bookmark/components/OutlineDialog.kt | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt index 367f12a3..69ec180c 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp as lerpDp import androidx.compose.ui.unit.sp import com.slax.reader.ui.bookmark.BookmarkDetailViewModel +import com.slax.reader.ui.bookmark.LocalToolbarVisible import com.slax.reader.ui.bookmark.states.OutlineDialogStatus import com.slax.reader.ui.bookmark.states.OutlineDialogStatus.* import com.slax.reader.utils.i18n @@ -55,6 +56,19 @@ fun OutlineDialog() { val anim = transition.outlineAnimations() val collapsedVisible = status == COLLAPSED || anim.collapsedAlpha > 0f + // 跟随 FloatingActionBar 的动画 + val visible by LocalToolbarVisible.current + val density = LocalDensity.current + val hiddenOffsetPx = remember(density) { with(density) { 150.dp.toPx() } } + val collapsedTranslationY = remember { Animatable(if (visible) 0f else hiddenOffsetPx) } + + LaunchedEffect(visible) { + collapsedTranslationY.animateTo( + targetValue = if (visible) 0f else hiddenOffsetPx, + animationSpec = tween(durationMillis = 300) + ) + } + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val screenWidth = maxWidth val screenHeight = maxHeight @@ -106,7 +120,8 @@ fun OutlineDialog() { modifier = Modifier .fillMaxSize() .padding(bottom = 58.dp) - .offset(x = (-87).dp), + .offset(x = (-87).dp) + .graphicsLayer { translationY = collapsedTranslationY.value }, contentAlignment = Alignment.BottomCenter ) { Box(contentAlignment = Alignment.Center) { From c3b9da1b29bdc365cfca693b1325aa7bf93da084 Mon Sep 17 00:00:00 2001 From: YaoJunchang Date: Tue, 17 Mar 2026 10:01:53 +0800 Subject: [PATCH 20/31] =?UTF-8?q?=F0=9F=8E=A8=20improve=20Markdown=20conte?= =?UTF-8?q?nt=20rendering=20in=20ExpandedOutlineDialog=20to=20prevent=20la?= =?UTF-8?q?g=20during=20animations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/bookmark/components/OutlineDialog.kt | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt index 69ec180c..fdacdad0 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OutlineDialog.kt @@ -112,7 +112,7 @@ fun OutlineDialog() { }, contentAlignment = Alignment.BottomCenter ) { - ExpandedOutlineDialog() + ExpandedOutlineDialog(animationSettled = transitionState.isIdle) } if (collapsedVisible) { @@ -195,7 +195,7 @@ private fun MorphOverlay( * 全屏展开状态的弹窗 */ @Composable -private fun ExpandedOutlineDialog() { +private fun ExpandedOutlineDialog(animationSettled: Boolean = false) { val viewModel = koinViewModel() val outlineState by viewModel.outlineDelegate.outlineState.collectAsState() @@ -238,6 +238,12 @@ private fun ExpandedOutlineDialog() { else -> { val scrollState = rememberScrollState() + // 只有在动画完全结束后才显示 Markdown 内容,避免在动画过程与 Markdown 内容渲染重合导致卡顿 + var contentReady by remember { mutableStateOf(false) } + LaunchedEffect(animationSettled) { + if (animationSettled) contentReady = true + } + LaunchedEffect(Unit) { // 恢复滚动位置 val savedPos = viewModel.outlineDelegate.savedScrollPosition @@ -263,14 +269,16 @@ private fun ExpandedOutlineDialog() { .verticalScroll(scrollState) .padding(top = 4.dp) ) { - MarkdownRenderer( - onLinkClick = { url -> - if (url.startsWith("#")) { - val anchorText = url.removePrefix("#") - viewModel.requestScrollToAnchor(anchorText) + if (contentReady) { + MarkdownRenderer( + onLinkClick = { url -> + if (url.startsWith("#")) { + val anchorText = url.removePrefix("#") + viewModel.requestScrollToAnchor(anchorText) + } } - } - ) + ) + } if (outlineState.isLoading) { Row( From d4fb2800744a0522a46a9c4da18f3a855396b21f Mon Sep 17 00:00:00 2001 From: YaoJunchang Date: Tue, 17 Mar 2026 10:06:57 +0800 Subject: [PATCH 21/31] =?UTF-8?q?=F0=9F=8E=A8=20enhance=20clickable=20inte?= =?UTF-8?q?raction=20in=20OverviewView=20with=20custom=20ripple=20effect?= =?UTF-8?q?=20for=20improved=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/slax/reader/ui/bookmark/components/OverviewView.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OverviewView.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OverviewView.kt index bad9be0e..2f01af1f 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OverviewView.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/components/OverviewView.kt @@ -1,11 +1,13 @@ package com.slax.reader.ui.bookmark.components import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -79,7 +81,10 @@ fun OverviewView( Box( modifier = Modifier .fillMaxWidth() - .clickable { + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(color = Color.Black.copy(alpha = 0.08f)) + ) { viewModel.overlayDelegate.showOverlay(BookmarkOverlay.Overview) bookmarkEvent.action("overview_interact", "open").send() } From 08399ba78ffaa33453e0e9079e83db46909eb252 Mon Sep 17 00:00:00 2001 From: YaoJunchang Date: Tue, 17 Mar 2026 10:24:47 +0800 Subject: [PATCH 22/31] =?UTF-8?q?=F0=9F=8E=A8=20adjust=20top=20spacing=20i?= =?UTF-8?q?n=20LoginScreen=20for=20responsive=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/com/slax/reader/ui/login/LoginScreen.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/login/LoginScreen.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/login/LoginScreen.kt index 639b22c6..d8a15697 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/login/LoginScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/login/LoginScreen.kt @@ -103,7 +103,10 @@ fun LoginScreen(navController: NavHostController) { Column( modifier = Modifier.weight(1f, fill = true) ) { - Spacer(modifier = Modifier.height(84.dp)) + BoxWithConstraints { + val topSpacing = (maxWidth * 128f / 375f).coerceAtMost(128.dp) + Spacer(modifier = Modifier.height(topSpacing)) + } Text( text = "login_welcome_title".i18n(), From 5fb7d53e998511f68fc84399a27aa28c75eba7cc Mon Sep 17 00:00:00 2001 From: YaoJunchang Date: Tue, 17 Mar 2026 11:12:57 +0800 Subject: [PATCH 23/31] =?UTF-8?q?=F0=9F=8E=A8=20add=20showCollapsed=20meth?= =?UTF-8?q?od=20to=20OutlineDelegate=20and=20invoke=20it=20based=20on=20su?= =?UTF-8?q?bscription=20status=20in=20ViewModel=20for=20improved=20dialog?= =?UTF-8?q?=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/com/slax/reader/ui/bookmark/ViewModel.kt | 9 +++++++++ .../slax/reader/ui/bookmark/states/OutlineDelegate.kt | 6 ++++++ 2 files changed, 15 insertions(+) diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt index 6fc764d2..03a9c028 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt @@ -127,6 +127,15 @@ class BookmarkDetailViewModel( } loadOutline() + + // 如果订阅了,Outline 按钮默认展示 + viewModelScope.launch { + val isSubscribed = subscriptionInfo.value?.checkIsSubscribed() == true + if (isSubscribed) { + outlineDelegate.showCollapsed() + } + } + refreshContent() } diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/OutlineDelegate.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/OutlineDelegate.kt index 89ec6751..c7624c57 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/OutlineDelegate.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/OutlineDelegate.kt @@ -159,6 +159,12 @@ class OutlineDelegate( } } + fun showCollapsed() { + if (_dialogStatus.value == OutlineDialogStatus.NONE) { + _dialogStatus.value = OutlineDialogStatus.COLLAPSED + } + } + fun showDialog() { transitionTo(OutlineDialogStatus.EXPANDED) } From 1c615718522d10547bf7dd5c2006af0abc06faaf Mon Sep 17 00:00:00 2001 From: YaoJunchang Date: Tue, 17 Mar 2026 11:14:33 +0800 Subject: [PATCH 24/31] feat: version update --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 8c75940f..666f90e8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,7 @@ hermesEnabled=true buildkonfig.flavor=dev appVersionName=1.0.7 org.gradle.jvmargs=-Xmx4096M -Dfile.encoding\=UTF-8 -appVersionCode=711 +appVersionCode=712 android.useAndroidX=true kotlin.native.binary.smallBinary=true ksp.useKSP2=true From c629fd4fb3916d37b549f52044e81dfeb6febf18 Mon Sep 17 00:00:00 2001 From: YaoJunchang Date: Tue, 17 Mar 2026 14:16:07 +0800 Subject: [PATCH 25/31] feat: update version --- iosApp/Versions.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iosApp/Versions.xcconfig b/iosApp/Versions.xcconfig index 612636c3..c47e6d67 100644 --- a/iosApp/Versions.xcconfig +++ b/iosApp/Versions.xcconfig @@ -1,2 +1,2 @@ BUNDLE_SHORT_VERSION_STRING = 1.0.7 -BUNDLE_VERSION = 711 +BUNDLE_VERSION = 712 From 3c93c958ba0cd0acfb359887b15f553cdc087e30 Mon Sep 17 00:00:00 2001 From: YaoJunchang Date: Wed, 18 Mar 2026 08:44:52 +0800 Subject: [PATCH 26/31] =?UTF-8?q?=F0=9F=8E=A8=20disable=20pretty=20print?= =?UTF-8?q?=20in=20HTML=20processing=20for=20improved=20content=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../commonMain/kotlin/com/slax/reader/domain/sync/Background.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/domain/sync/Background.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/domain/sync/Background.kt index 985cebb9..05f74959 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/domain/sync/Background.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/domain/sync/Background.kt @@ -238,6 +238,7 @@ class BackgroundDomain( private fun processContent(html: String): ProcessedContent { return try { val doc = Ksoup.parse(html) + doc.outputSettings().prettyPrint(false) val urls = mutableListOf() doc.select("img").forEach { img -> val src = img.attr("src") From 28c0b6ccb36af5f288cd1878ff798dae9d2659ba Mon Sep 17 00:00:00 2001 From: YaoJunchang Date: Wed, 18 Mar 2026 09:35:32 +0800 Subject: [PATCH 27/31] feat: implement bookmark deletion with animation and navigation back --- .../kotlin/com/slax/reader/ui/Navigation.kt | 21 +++++- .../slax/reader/ui/bookmark/DetailScreen.kt | 4 ++ .../com/slax/reader/ui/bookmark/ViewModel.kt | 18 ++--- .../slax/reader/ui/inbox/InboxListScreen.kt | 9 ++- .../com/slax/reader/ui/inbox/ViewModel.kt | 29 ++++++++ .../slax/reader/ui/inbox/compenents/List.kt | 67 +++++++++++++++++-- 6 files changed, 126 insertions(+), 22 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/Navigation.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/Navigation.kt index 3f0cade6..757516a2 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/Navigation.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/Navigation.kt @@ -114,6 +114,13 @@ fun SlaxNavigation( navCtrl.popBackStack() } + is DetailScreenEvent.DeleteAndBack -> { + navCtrl.previousBackStackEntry?.savedStateHandle?.set( + "deletedBookmarkId", event.bookmarkId + ) + navCtrl.popBackStack() + } + DetailScreenEvent.NavigateToSubscription -> { navCtrl.navigate(SubscriptionManagerRoutes) subscriptionEvent.view().source("dialog").send() @@ -135,8 +142,18 @@ fun SlaxNavigation( .send() } } - composable { - InboxListScreen(navCtrl) + composable { backStackEntry -> + val deletedBookmarkId by backStackEntry.savedStateHandle + .getStateFlow("deletedBookmarkId", null) + .collectAsState() + + InboxListScreen(navCtrl, deletedBookmarkId = deletedBookmarkId) + + LaunchedEffect(deletedBookmarkId) { + if (deletedBookmarkId != null) { + backStackEntry.savedStateHandle.remove("deletedBookmarkId") + } + } LaunchedEffect(Unit) { bookmarkListEvent.view().send() } } composable { diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/DetailScreen.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/DetailScreen.kt index 4bb1aab0..0a8c7851 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/DetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/DetailScreen.kt @@ -14,6 +14,7 @@ sealed interface DetailScreenEvent { data object BackClick : DetailScreenEvent data object NavigateToSubscription : DetailScreenEvent data class NavigateToFeedback(val params: FeedbackPageParams) : DetailScreenEvent + data class DeleteAndBack(val bookmarkId: String) : DetailScreenEvent } @Composable @@ -36,6 +37,9 @@ fun DetailScreen(bookmarkId: String, onEvent: (DetailScreenEvent) -> Unit) { is BookmarkDetailEffect.NavigateToFeedback -> { onEvent(DetailScreenEvent.NavigateToFeedback(effect.params)) } + is BookmarkDetailEffect.DeleteAndNavigateBack -> { + onEvent(DetailScreenEvent.DeleteAndBack(effect.bookmarkId)) + } is BookmarkDetailEffect.ScrollToAnchor -> { webViewState.scrollToAnchor(effect.anchor) } diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt index 03a9c028..04488ba6 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt @@ -51,7 +51,7 @@ sealed interface BookmarkDetailEffect { data object NavigateBack : BookmarkDetailEffect data object NavigateToSubscription : BookmarkDetailEffect data class NavigateToFeedback(val params: FeedbackPageParams) : BookmarkDetailEffect - + data class DeleteAndNavigateBack(val bookmarkId: String) : BookmarkDetailEffect data class ScrollToAnchor(val anchor: String) : BookmarkDetailEffect } @@ -196,18 +196,12 @@ class BookmarkDetailViewModel( } fun confirmDeleteBookmark() { + val id = _bookmarkId.value ?: return + _deleteConfirmVisible.value = false + overlayDelegate.dismissOverlay(BookmarkOverlay.Toolbar) + bookmarkEvent.action("delete").send() viewModelScope.launch { - runCatching { bookmarkDelegate.deleteBookmark() } - .onSuccess { - bookmarkEvent.action("delete").send() - _deleteConfirmVisible.value = false - overlayDelegate.dismissOverlay(BookmarkOverlay.Toolbar) - requestNavigateBack() - } - .onFailure { - bookmarkEvent.action("delete_failed").send() - _deleteConfirmVisible.value = false - } + _effects.emit(BookmarkDetailEffect.DeleteAndNavigateBack(id)) } } diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/InboxListScreen.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/InboxListScreen.kt index 69d23026..35f1c496 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/InboxListScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/InboxListScreen.kt @@ -37,7 +37,7 @@ import slax_reader_client.composeapp.generated.resources.ic_inbox_tab import slax_reader_client.composeapp.generated.resources.ic_xs_inbox_add @Composable -fun InboxListScreen(navCtrl: NavController) { +fun InboxListScreen(navCtrl: NavController, deletedBookmarkId: String? = null) { val inboxViewModel = koinInject() val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() @@ -45,6 +45,13 @@ fun InboxListScreen(navCtrl: NavController) { var showAddLinkDialog by remember { mutableStateOf(false) } var editingBookmark by remember { mutableStateOf(null) } + // 从详情页返回时触发删除动画 + LaunchedEffect(deletedBookmarkId) { + if (deletedBookmarkId != null) { + inboxViewModel.markPendingDelete(deletedBookmarkId) + } + } + println("[watch][UI] recomposition InboxListScreen") Sidebar( diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/ViewModel.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/ViewModel.kt index f22d02de..a0425fa3 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/ViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/ViewModel.kt @@ -9,13 +9,18 @@ import com.slax.reader.data.database.model.InboxListBookmarkItem import com.slax.reader.domain.coordinator.CoordinatorDomain import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class InboxListViewModel( @@ -45,6 +50,11 @@ class InboxListViewModel( private val _scrollToTopEvent = MutableSharedFlow(extraBufferCapacity = 1) val scrollToTopEvent: SharedFlow = _scrollToTopEvent.asSharedFlow() + private val _pendingDeleteId = MutableStateFlow(null) + val pendingDeleteId: StateFlow = _pendingDeleteId.asStateFlow() + + private var pendingDeleteJob: Job? = null + private val _processingUrlEvent = MutableSharedFlow(extraBufferCapacity = 1) val processingUrlEvent: SharedFlow = _processingUrlEvent.asSharedFlow() @@ -52,6 +62,25 @@ class InboxListViewModel( _scrollToTopEvent.tryEmit(Unit) } + fun markPendingDelete(bookmarkId: String) { + _pendingDeleteId.value = bookmarkId + // 在 viewModelScope 中启动兜底删除,即使动画被中断也能执行 + pendingDeleteJob?.cancel() + pendingDeleteJob = viewModelScope.launch { + delay(1500L) + commitDelete() + } + } + + fun commitDelete() { + pendingDeleteJob?.cancel() + val id = _pendingDeleteId.value ?: return + _pendingDeleteId.value = null + viewModelScope.launch { + withContext(Dispatchers.IO) { bookmarkDao.deleteBookmark(id) } + } + } + fun emitProcessingUrl(url: String) { _processingUrlEvent.tryEmit(url) } diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/compenents/List.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/compenents/List.kt index 7110c2f8..6607ab56 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/compenents/List.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/compenents/List.kt @@ -1,5 +1,11 @@ package com.slax.reader.ui.inbox.compenents +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown @@ -23,6 +29,7 @@ import androidx.navigation.NavController import com.slax.reader.data.database.model.InboxListBookmarkItem import com.slax.reader.ui.inbox.InboxListViewModel import com.slax.reader.utils.i18n +import kotlinx.coroutines.delay @Composable fun ArticleList( @@ -33,6 +40,7 @@ fun ArticleList( println("[watch][UI] recomposition ArticleList") val bookmarks by viewModel.bookmarks.collectAsState() + val pendingDeleteId by viewModel.pendingDeleteId.collectAsState() val lazyListState = rememberLazyListState() val dividerLine: @Composable () -> Unit = remember { @@ -69,14 +77,59 @@ fun ArticleList( key = { _, bookmark -> bookmark.id }, contentType = { _, _ -> "bookmark" } ) { index, bookmark -> - BookmarkItemRow( - navCtrl = navCtrl, - viewModel = viewModel, - bookmark = bookmark, - onEditTitle = onEditTitle - ) + var itemVisible by remember { mutableStateOf(true) } + val overlayAlpha = remember { Animatable(0f) } + + val baseDuration = 600 + + LaunchedEffect(pendingDeleteId) { + if (bookmark.id == pendingDeleteId && itemVisible) { + delay(50) + // 先播放背景变暗动画 + overlayAlpha.animateTo( + targetValue = 0.08f, + animationSpec = tween(baseDuration / 2) + ) + delay(100) + // 再触发收缩消失 + itemVisible = false + } + } + - dividerLine() + AnimatedVisibility( + visible = itemVisible, + exit = shrinkVertically( + animationSpec = tween(baseDuration + 100, easing = FastOutSlowInEasing) + ) + fadeOut(animationSpec = tween(baseDuration)), + ) { + Box { + Column { + BookmarkItemRow( + navCtrl = navCtrl, + viewModel = viewModel, + bookmark = bookmark, + onEditTitle = onEditTitle + ) + dividerLine() + } + // 变暗遮罩层 + if (overlayAlpha.value > 0f) { + Box( + modifier = Modifier + .matchParentSize() + .background(Color.Black.copy(alpha = overlayAlpha.value)) + ) + } + } + } + + if (!itemVisible) { + LaunchedEffect(Unit) { + delay(baseDuration + 150L) + viewModel.commitDelete() + } + } } item { From 60b1b35dfc1d5cf78d47a4d841dac888b82c1e19 Mon Sep 17 00:00:00 2001 From: YaoJunchang Date: Wed, 18 Mar 2026 10:25:18 +0800 Subject: [PATCH 28/31] feat: implement dismissable item for bookmark deletion animation --- .../reader/const/component/DismissableItem.kt | 67 ++++++++++++++++ .../kotlin/com/slax/reader/di/di.kt | 2 + .../domain/bookmark/BookmarkActionBus.kt | 22 ++++++ .../kotlin/com/slax/reader/ui/Navigation.kt | 21 +---- .../slax/reader/ui/bookmark/DetailScreen.kt | 4 - .../com/slax/reader/ui/bookmark/ViewModel.kt | 15 ++-- .../slax/reader/ui/inbox/InboxListScreen.kt | 9 +-- .../com/slax/reader/ui/inbox/ViewModel.kt | 59 +++++++++----- .../ui/inbox/compenents/BookmarkItem.kt | 2 +- .../slax/reader/ui/inbox/compenents/List.kt | 76 +++++-------------- 10 files changed, 162 insertions(+), 115 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/slax/reader/const/component/DismissableItem.kt create mode 100644 composeApp/src/commonMain/kotlin/com/slax/reader/domain/bookmark/BookmarkActionBus.kt diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/const/component/DismissableItem.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/const/component/DismissableItem.kt new file mode 100644 index 00000000..016fef73 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/const/component/DismissableItem.kt @@ -0,0 +1,67 @@ +package com.slax.reader.const.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import kotlinx.coroutines.delay + +/** + * 带消失动画的容器:先变暗,再收缩淡出。 + * + * @param isDismissed 触发消失动画 + * @param durationMs 收缩+淡出阶段时长(毫秒) + * @param onDismissed 动画完全结束后的回调 + * @param content 被包裹的内容 + */ +@Composable +fun DismissableItem( + isDismissed: Boolean, + durationMs: Int = 600, + onDismissed: () -> Unit = {}, + content: @Composable () -> Unit, +) { + var itemVisible by remember { mutableStateOf(true) } + val overlayAlpha = remember { Animatable(0f) } + + LaunchedEffect(isDismissed) { + if (isDismissed && itemVisible) { + delay(50) + overlayAlpha.animateTo(0.08f, tween(durationMs / 2)) + delay(100) + itemVisible = false + } + } + + AnimatedVisibility( + visible = itemVisible, + exit = shrinkVertically( + animationSpec = tween(durationMs + 100, easing = FastOutSlowInEasing) + ) + fadeOut(animationSpec = tween(durationMs)), + ) { + Box { + content() + if (overlayAlpha.value > 0f) { + Box( + modifier = Modifier + .matchParentSize() + .background(Color.Black.copy(alpha = overlayAlpha.value)) + ) + } + } + } + + if (!itemVisible) { + LaunchedEffect(Unit) { + delay(durationMs + 150L) + onDismissed() + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/di/di.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/di/di.kt index 6fdeba14..af6c1804 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/di/di.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/di/di.kt @@ -13,6 +13,7 @@ import com.slax.reader.data.file.FileManager import com.slax.reader.data.network.ApiService import com.slax.reader.data.preferences.preferencesPlatformModule import com.slax.reader.domain.auth.AuthDomain +import com.slax.reader.domain.bookmark.BookmarkActionBus import com.slax.reader.domain.coordinator.CoordinatorDomain import com.slax.reader.domain.image.ImageDownloadManager import com.slax.reader.domain.sync.BackgroundDomain @@ -75,6 +76,7 @@ val domainModule = module { single { BackgroundDomain(get(), get(), get(), get(), get(), get()) } single { CoordinatorDomain(get(), get(), get()) } single { ImageDownloadManager(get(), get()) } + single { BookmarkActionBus() } } val appModule = module { diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/domain/bookmark/BookmarkActionBus.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/domain/bookmark/BookmarkActionBus.kt new file mode 100644 index 00000000..5a0782d8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/domain/bookmark/BookmarkActionBus.kt @@ -0,0 +1,22 @@ +package com.slax.reader.domain.bookmark + +/** + * 跨页面书签操作事件总线(单例)。 + * 详情页发出删除事件后,由列表页在可见时消费并播放动画。 + */ +class BookmarkActionBus { + // 待消费的删除 ID,保证即使列表页 composable 不在组合树中也不会丢失事件 + @Volatile + var pendingDeleteId: String? = null + private set + + fun emitDelete(bookmarkId: String) { + pendingDeleteId = bookmarkId + } + + fun consumePendingDelete(): String? { + val id = pendingDeleteId + pendingDeleteId = null + return id + } +} diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/Navigation.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/Navigation.kt index 757516a2..3f0cade6 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/Navigation.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/Navigation.kt @@ -114,13 +114,6 @@ fun SlaxNavigation( navCtrl.popBackStack() } - is DetailScreenEvent.DeleteAndBack -> { - navCtrl.previousBackStackEntry?.savedStateHandle?.set( - "deletedBookmarkId", event.bookmarkId - ) - navCtrl.popBackStack() - } - DetailScreenEvent.NavigateToSubscription -> { navCtrl.navigate(SubscriptionManagerRoutes) subscriptionEvent.view().source("dialog").send() @@ -142,18 +135,8 @@ fun SlaxNavigation( .send() } } - composable { backStackEntry -> - val deletedBookmarkId by backStackEntry.savedStateHandle - .getStateFlow("deletedBookmarkId", null) - .collectAsState() - - InboxListScreen(navCtrl, deletedBookmarkId = deletedBookmarkId) - - LaunchedEffect(deletedBookmarkId) { - if (deletedBookmarkId != null) { - backStackEntry.savedStateHandle.remove("deletedBookmarkId") - } - } + composable { + InboxListScreen(navCtrl) LaunchedEffect(Unit) { bookmarkListEvent.view().send() } } composable { diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/DetailScreen.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/DetailScreen.kt index 0a8c7851..4bb1aab0 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/DetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/DetailScreen.kt @@ -14,7 +14,6 @@ sealed interface DetailScreenEvent { data object BackClick : DetailScreenEvent data object NavigateToSubscription : DetailScreenEvent data class NavigateToFeedback(val params: FeedbackPageParams) : DetailScreenEvent - data class DeleteAndBack(val bookmarkId: String) : DetailScreenEvent } @Composable @@ -37,9 +36,6 @@ fun DetailScreen(bookmarkId: String, onEvent: (DetailScreenEvent) -> Unit) { is BookmarkDetailEffect.NavigateToFeedback -> { onEvent(DetailScreenEvent.NavigateToFeedback(effect.params)) } - is BookmarkDetailEffect.DeleteAndNavigateBack -> { - onEvent(DetailScreenEvent.DeleteAndBack(effect.bookmarkId)) - } is BookmarkDetailEffect.ScrollToAnchor -> { webViewState.scrollToAnchor(effect.anchor) } diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt index 04488ba6..ea62028f 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt @@ -14,6 +14,7 @@ import com.slax.reader.data.network.ApiService import com.slax.reader.data.preferences.AppPreferences import com.slax.reader.data.preferences.ContinueReadingBookmark import com.slax.reader.domain.sync.BackgroundDomain +import com.slax.reader.domain.bookmark.BookmarkActionBus import com.slax.reader.ui.bookmark.states.BookmarkDelegate import com.slax.reader.ui.bookmark.states.BookmarkOverlay import com.slax.reader.ui.bookmark.states.CommentDelegate @@ -51,7 +52,6 @@ sealed interface BookmarkDetailEffect { data object NavigateBack : BookmarkDetailEffect data object NavigateToSubscription : BookmarkDetailEffect data class NavigateToFeedback(val params: FeedbackPageParams) : BookmarkDetailEffect - data class DeleteAndNavigateBack(val bookmarkId: String) : BookmarkDetailEffect data class ScrollToAnchor(val anchor: String) : BookmarkDetailEffect } @@ -70,6 +70,7 @@ class BookmarkDetailViewModel( private val apiService: ApiService, private val appPreferences: AppPreferences, private val database: PowerSyncDatabase, + private val bookmarkActionBus: BookmarkActionBus, ) : ViewModel() { companion object { @@ -196,12 +197,14 @@ class BookmarkDetailViewModel( } fun confirmDeleteBookmark() { - val id = _bookmarkId.value ?: return - _deleteConfirmVisible.value = false - overlayDelegate.dismissOverlay(BookmarkOverlay.Toolbar) - bookmarkEvent.action("delete").send() viewModelScope.launch { - _effects.emit(BookmarkDetailEffect.DeleteAndNavigateBack(id)) + val id = _bookmarkId.value ?: return@launch + _deleteConfirmVisible.value = false + overlayDelegate.dismissOverlay(BookmarkOverlay.Toolbar) + // 只通知列表页播放删除动画,实际删除由列表页的 ViewModel 在动画结束后执行 + bookmarkActionBus.emitDelete(id) + bookmarkEvent.action("delete").send() + requestNavigateBack() } } diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/InboxListScreen.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/InboxListScreen.kt index 35f1c496..69d23026 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/InboxListScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/InboxListScreen.kt @@ -37,7 +37,7 @@ import slax_reader_client.composeapp.generated.resources.ic_inbox_tab import slax_reader_client.composeapp.generated.resources.ic_xs_inbox_add @Composable -fun InboxListScreen(navCtrl: NavController, deletedBookmarkId: String? = null) { +fun InboxListScreen(navCtrl: NavController) { val inboxViewModel = koinInject() val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() @@ -45,13 +45,6 @@ fun InboxListScreen(navCtrl: NavController, deletedBookmarkId: String? = null) { var showAddLinkDialog by remember { mutableStateOf(false) } var editingBookmark by remember { mutableStateOf(null) } - // 从详情页返回时触发删除动画 - LaunchedEffect(deletedBookmarkId) { - if (deletedBookmarkId != null) { - inboxViewModel.markPendingDelete(deletedBookmarkId) - } - } - println("[watch][UI] recomposition InboxListScreen") Sidebar( diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/ViewModel.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/ViewModel.kt index a0425fa3..90b12e5a 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/ViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/ViewModel.kt @@ -7,6 +7,7 @@ import com.slax.reader.data.database.dao.LocalBookmarkDao import com.slax.reader.data.database.dao.UserDao import com.slax.reader.data.database.model.InboxListBookmarkItem import com.slax.reader.domain.coordinator.CoordinatorDomain +import com.slax.reader.domain.bookmark.BookmarkActionBus import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.Job @@ -27,7 +28,8 @@ class InboxListViewModel( private val userDao: UserDao, private val bookmarkDao: BookmarkDao, private val localBookmarkDao: LocalBookmarkDao, - private val coordinatorDomain: CoordinatorDomain + private val coordinatorDomain: CoordinatorDomain, + private val bookmarkActionBus: BookmarkActionBus, ) : ViewModel() { val userInfo = userDao.watchUserInfo() val syncState = coordinatorDomain.syncState @@ -53,34 +55,57 @@ class InboxListViewModel( private val _pendingDeleteId = MutableStateFlow(null) val pendingDeleteId: StateFlow = _pendingDeleteId.asStateFlow() - private var pendingDeleteJob: Job? = null - private val _processingUrlEvent = MutableSharedFlow(extraBufferCapacity = 1) val processingUrlEvent: SharedFlow = _processingUrlEvent.asSharedFlow() + // 兜底删除定时器,确保动画被中断时也能执行删除 + private var pendingDeleteJob: Job? = null + fun scrollToTop() { _scrollToTopEvent.tryEmit(Unit) } - fun markPendingDelete(bookmarkId: String) { - _pendingDeleteId.value = bookmarkId - // 在 viewModelScope 中启动兜底删除,即使动画被中断也能执行 + /** + * 当列表页 composable 进入组合树时调用, + * 从 BookmarkActionBus 消费待删除 ID 并触发动画。 + * 确保动画只在列表页可见时才播放。 + */ + fun activatePendingDelete() { + val id = bookmarkActionBus.consumePendingDelete() ?: return + _pendingDeleteId.value = id + startDeleteFallback(id) + } + + fun onDeleteAnimationFinished() { + val id = _pendingDeleteId.value + _pendingDeleteId.value = null pendingDeleteJob?.cancel() - pendingDeleteJob = viewModelScope.launch { - delay(1500L) - commitDelete() + if (id != null) { + viewModelScope.launch { commitDelete(id) } } } - fun commitDelete() { + private suspend fun commitDelete(bookmarkId: String) { + withContext(Dispatchers.IO) { bookmarkDao.deleteBookmark(bookmarkId) } + } + + private fun startDeleteFallback(id: String) { pendingDeleteJob?.cancel() - val id = _pendingDeleteId.value ?: return - _pendingDeleteId.value = null - viewModelScope.launch { - withContext(Dispatchers.IO) { bookmarkDao.deleteBookmark(id) } + pendingDeleteJob = viewModelScope.launch { + delay(3000L) + commitDelete(id) } } + /** + * 列表页直接触发删除(长按菜单), + * 无需经过 ActionBus 缓冲,直接设置 pendingDeleteId 播放动画。 + */ + fun requestDeleteBookmark(bookmarkId: String) { + _pendingDeleteId.value = bookmarkId + startDeleteFallback(bookmarkId) + } + fun emitProcessingUrl(url: String) { _processingUrlEvent.tryEmit(url) } @@ -99,11 +124,7 @@ class InboxListViewModel( bookmarkDao.updateBookmarkArchive(bookmarkId, if (isArchive) 1 else 0) } - suspend fun deleteBookmark(bookmarkId: String) = withContext(Dispatchers.IO) { - bookmarkDao.deleteBookmark(bookmarkId) - } - suspend fun addLinkBookmark(url: String) = withContext(Dispatchers.IO) { return@withContext bookmarkDao.createBookmark(url) } -} +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/compenents/BookmarkItem.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/compenents/BookmarkItem.kt index ce75ae6f..7a9381a4 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/compenents/BookmarkItem.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/compenents/BookmarkItem.kt @@ -429,7 +429,7 @@ fun BookmarkItemRow( scope.launch { menuTriggerSource = MenuTriggerSource.NONE isLongPressed = false - viewModel.deleteBookmark(bookmark.id) + viewModel.requestDeleteBookmark(bookmark.id) } } ) diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/compenents/List.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/compenents/List.kt index 6607ab56..e70e037a 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/compenents/List.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/compenents/List.kt @@ -1,11 +1,5 @@ package com.slax.reader.ui.inbox.compenents -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown @@ -26,10 +20,10 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController +import com.slax.reader.const.component.DismissableItem import com.slax.reader.data.database.model.InboxListBookmarkItem import com.slax.reader.ui.inbox.InboxListViewModel import com.slax.reader.utils.i18n -import kotlinx.coroutines.delay @Composable fun ArticleList( @@ -53,6 +47,11 @@ fun ArticleList( } } + // 列表页进入组合树时,消费详情页传来的待删除 ID 并触发动画 + LaunchedEffect(Unit) { + viewModel.activatePendingDelete() + } + if (bookmarks.isEmpty()) { val hasSynced by viewModel.hasSynced.collectAsState() Box( @@ -76,58 +75,19 @@ fun ArticleList( items = bookmarks, key = { _, bookmark -> bookmark.id }, contentType = { _, _ -> "bookmark" } - ) { index, bookmark -> - var itemVisible by remember { mutableStateOf(true) } - val overlayAlpha = remember { Animatable(0f) } - - val baseDuration = 600 - - LaunchedEffect(pendingDeleteId) { - if (bookmark.id == pendingDeleteId && itemVisible) { - delay(50) - // 先播放背景变暗动画 - overlayAlpha.animateTo( - targetValue = 0.08f, - animationSpec = tween(baseDuration / 2) - ) - delay(100) - // 再触发收缩消失 - itemVisible = false - } - } - - - AnimatedVisibility( - visible = itemVisible, - exit = shrinkVertically( - animationSpec = tween(baseDuration + 100, easing = FastOutSlowInEasing) - ) + fadeOut(animationSpec = tween(baseDuration)), + ) { _, bookmark -> + DismissableItem( + isDismissed = bookmark.id == pendingDeleteId, + onDismissed = { viewModel.onDeleteAnimationFinished() }, ) { - Box { - Column { - BookmarkItemRow( - navCtrl = navCtrl, - viewModel = viewModel, - bookmark = bookmark, - onEditTitle = onEditTitle - ) - dividerLine() - } - // 变暗遮罩层 - if (overlayAlpha.value > 0f) { - Box( - modifier = Modifier - .matchParentSize() - .background(Color.Black.copy(alpha = overlayAlpha.value)) - ) - } - } - } - - if (!itemVisible) { - LaunchedEffect(Unit) { - delay(baseDuration + 150L) - viewModel.commitDelete() + Column { + BookmarkItemRow( + navCtrl = navCtrl, + viewModel = viewModel, + bookmark = bookmark, + onEditTitle = onEditTitle + ) + dividerLine() } } } From 8aeaa4813d18816c0252c3bc060412e18897b90a Mon Sep 17 00:00:00 2001 From: YaoJunchang Date: Thu, 19 Mar 2026 10:05:51 +0800 Subject: [PATCH 29/31] =?UTF-8?q?=F0=9F=8E=A8=20add=20localized=20strings?= =?UTF-8?q?=20for=20toolbar=20actions=20in=20detail=20view=20for=20improve?= =?UTF-8?q?d=20user=20experience?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/slax/reader/ui/bookmark/ViewModel.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt index 0458da4f..2f20d1e1 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt @@ -198,13 +198,13 @@ class BookmarkDetailViewModel( fun confirmDeleteBookmark() { viewModelScope.launch { - val id = _bookmarkId.value ?: return@launch - _deleteConfirmVisible.value = false - overlayDelegate.dismissOverlay(BookmarkOverlay.Toolbar) - // 只通知列表页播放删除动画,实际删除由列表页的 ViewModel 在动画结束后执行 - bookmarkActionBus.emitDelete(id) - bookmarkEvent.action("delete").send() - requestNavigateBack() + val id = _bookmarkId.value ?: return@launch + _deleteConfirmVisible.value = false + overlayDelegate.dismissOverlay(BookmarkOverlay.Toolbar) + // 只通知列表页播放删除动画,实际删除由列表页的 ViewModel 在动画结束后执行 + bookmarkActionBus.emitDelete(id) + bookmarkEvent.action("delete").send() + requestNavigateBack() } } From d4daf563555c2bb7b97d07c6e13465f5e9854959 Mon Sep 17 00:00:00 2001 From: YaoJunchang Date: Thu, 19 Mar 2026 10:06:41 +0800 Subject: [PATCH 30/31] chore: update appVersionCode to 714 for upcoming release --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 666f90e8..4873ef04 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,7 @@ hermesEnabled=true buildkonfig.flavor=dev appVersionName=1.0.7 org.gradle.jvmargs=-Xmx4096M -Dfile.encoding\=UTF-8 -appVersionCode=712 +appVersionCode=714 android.useAndroidX=true kotlin.native.binary.smallBinary=true ksp.useKSP2=true From a63eaeef3f480db05974187a689df44429499549 Mon Sep 17 00:00:00 2001 From: YaoJunchang Date: Fri, 20 Mar 2026 09:02:56 +0800 Subject: [PATCH 31/31] refactor: remove unnecessary @Volatile annotation from pendingDeleteId --- .../kotlin/com/slax/reader/domain/bookmark/BookmarkActionBus.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/domain/bookmark/BookmarkActionBus.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/domain/bookmark/BookmarkActionBus.kt index 5a0782d8..fdb6c760 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/domain/bookmark/BookmarkActionBus.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/domain/bookmark/BookmarkActionBus.kt @@ -6,7 +6,6 @@ package com.slax.reader.domain.bookmark */ class BookmarkActionBus { // 待消费的删除 ID,保证即使列表页 composable 不在组合树中也不会丢失事件 - @Volatile var pendingDeleteId: String? = null private set