[优化] 一些界面问题

This commit is contained in:
HapeLee
2026-01-04 03:39:58 +08:00
parent 8362a0fe8b
commit bdb7c77442
7 changed files with 302 additions and 234 deletions
@@ -16,6 +16,7 @@ import io.legado.app.constant.NotificationId
import io.legado.app.constant.PreferKey
import io.legado.app.receiver.NetworkChangedListener
import io.legado.app.utils.NetworkUtils
import io.legado.app.utils.eventBus.FlowEventBus
import io.legado.app.utils.getPrefBoolean
import io.legado.app.utils.getPrefInt
import io.legado.app.utils.postEvent
@@ -108,6 +109,7 @@ class WebService : BaseService() {
}
startForegroundNotification()
postEvent(EventBus.WEB_SERVICE, hostAddress)
FlowEventBus.post(EventBus.WEB_SERVICE, hostAddress)
}
}
@@ -141,6 +143,7 @@ class WebService : BaseService() {
webSocketServer?.stop()
}
postEvent(EventBus.WEB_SERVICE, "")
FlowEventBus.post(EventBus.WEB_SERVICE, "")
upTile(false)
}
@@ -170,6 +173,7 @@ class WebService : BaseService() {
hostAddress = notificationList.first()
isRun = true
postEvent(EventBus.WEB_SERVICE, hostAddress)
FlowEventBus.post(EventBus.WEB_SERVICE, hostAddress)
startForegroundNotification()
} catch (e: IOException) {
toastOnUi(e.localizedMessage ?: "")
@@ -24,7 +24,6 @@ import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Source
import androidx.compose.material.icons.filled.Translate
import androidx.compose.material.icons.filled.Web
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -37,9 +36,6 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
@@ -67,7 +63,6 @@ fun MyScreen(
) {
val uiState by viewModel.uiState.collectAsState()
var showMenu by remember { mutableStateOf(false) }
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
Scaffold(
@@ -126,35 +121,26 @@ fun MyScreen(
}
)
},
onClick = {
viewModel.onEvent(PrefClickEvent.ToggleWebService)
},
onLongClick = {
if (uiState.isWebServiceRun) {
showMenu = true
dropdownMenu = if (uiState.isWebServiceRun) {
{ onDismiss ->
DropdownMenuItem(
text = { Text("复制地址") },
onClick = {
onNavigate(PrefClickEvent.CopyUrl(uiState.webServiceAddress))
onDismiss()
}
)
DropdownMenuItem(
text = { Text("浏览器打开") },
onClick = {
onNavigate(PrefClickEvent.OpenUrl(uiState.webServiceAddress))
onDismiss()
}
)
}
}
} else null,
onClick = { }
)
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
text = { Text("复制地址") },
onClick = {
onNavigate(PrefClickEvent.CopyUrl(uiState.webServiceAddress))
showMenu = false
}
)
DropdownMenuItem(
text = { Text("浏览器打开") },
onClick = {
onNavigate(PrefClickEvent.OpenUrl(uiState.webServiceAddress))
showMenu = false
}
)
}
}
SplicedColumnGroup(
@@ -2,10 +2,15 @@ package io.legado.app.ui.main.my
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import io.legado.app.constant.EventBus
import io.legado.app.service.WebService
import io.legado.app.utils.eventBus.FlowEventBus
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class MyUiState(
val isWebServiceRun: Boolean = false,
@@ -17,7 +22,6 @@ sealed class PrefClickEvent {
data class CopyUrl(val url: String) : PrefClickEvent()
data class ShowMd(val title: String, val path: String) : PrefClickEvent()
data class StartActivity(val destination: Class<*>, val configTag: String? = null) : PrefClickEvent()
object ShowWebServiceMenu : PrefClickEvent()
object ToggleWebService : PrefClickEvent()
object ExitApp : PrefClickEvent()
}
@@ -29,40 +33,40 @@ class MyViewModel(
private val _uiState = MutableStateFlow(
MyUiState(
isWebServiceRun = WebService.isRun,
webServiceAddress = if (WebService.isRun) {
WebService.hostAddress
} else ""
webServiceAddress = WebService.hostAddress
)
)
val uiState: StateFlow<MyUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
FlowEventBus.with<String>(EventBus.WEB_SERVICE)
.collect { address ->
_uiState.update { state ->
state.copy(
isWebServiceRun = address.isNotEmpty(),
webServiceAddress = address
)
}
}
}
}
fun onEvent(event: PrefClickEvent) {
when (event) {
PrefClickEvent.ToggleWebService -> {
val newRun = !_uiState.value.isWebServiceRun
if (newRun) {
val currentIsRun = _uiState.value.isWebServiceRun
if (!currentIsRun) {
WebService.start(getApplication())
} else {
WebService.stop(getApplication())
_uiState.update { it.copy(isWebServiceRun = false, webServiceAddress = "") }
}
updateWebServiceState()
}
PrefClickEvent.ShowWebServiceMenu -> {
// UI 自己处理 showMenu
}
else -> Unit
}
}
private fun updateWebServiceState() {
_uiState.value = MyUiState(
isWebServiceRun = WebService.isRun,
webServiceAddress = if (WebService.isRun) {
WebService.hostAddress
} else ""
)
}
}
@@ -57,6 +57,7 @@ import io.legado.app.data.repository.UploadRepository
import io.legado.app.ui.replace.edit.ReplaceEditActivity
import io.legado.app.ui.widget.components.ActionItem
import io.legado.app.ui.widget.components.AnimatedText
import io.legado.app.ui.widget.components.EmptyMessageView
import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
import io.legado.app.ui.widget.components.SearchBarSection
import io.legado.app.ui.widget.components.SelectionBottomBar
@@ -494,139 +495,146 @@ fun ReplaceRuleScreen(
.padding(padding)
.fillMaxSize()
) {
FastScrollLazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize(),
contentPadding = PaddingValues(
top = 8.dp,
bottom = 120.dp
),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(rules, key = { it.id }) { ui ->
val isSelected = selectedRuleIds.contains(ui.id)
ReorderableItem(
state = reorderableState,
key = ui.id
) { isDragging ->
if (rules.isEmpty()) {
EmptyMessageView(
modifier = Modifier.fillMaxSize(),
message = "没有替换规则!"
)
} else {
FastScrollLazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize(),
contentPadding = PaddingValues(
top = 8.dp,
bottom = 120.dp
),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(rules, key = { it.id }) { ui ->
val isSelected = selectedRuleIds.contains(ui.id)
ReorderableItem(
state = reorderableState,
key = ui.id
) { isDragging ->
val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp)
ReplaceRuleItem(
modifier = Modifier
.padding(horizontal = 12.dp)
.zIndex(if (isDragging) 1f else 0f)
.shadow(
elevation = elevation,
shape = MaterialTheme.shapes.medium,
clip = false
)
.then(
if (canReorder) {
Modifier.longPressDraggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
},
interactionSource = remember { MutableInteractionSource() }
)
} else {
Modifier
}
)
.animateItem(),
name = ui.name,
isEnabled = ui.isEnabled,
isSelected = isSelected,
inSelectionMode = inSelectionMode,
onEnabledChange = { enabled ->
viewModel.update(ui.rule.copy(isEnabled = enabled))
},
onDelete = { showDeleteRuleDialog = ui.rule },
onToTop = { viewModel.toTop(ui.rule) },
onToBottom = { viewModel.toBottom(ui.rule) },
onToggleSelection = {
viewModel.toggleSelection(ui.id)
},
onClickEdit = {
context.startActivity(
ReplaceEditActivity.startIntent(context, ui.id)
)
}
)
val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp)
ReplaceRuleItem(
modifier = Modifier
.padding(horizontal = 12.dp)
.zIndex(if (isDragging) 1f else 0f)
.shadow(
elevation = elevation,
shape = MaterialTheme.shapes.medium,
clip = false
)
.then(
if (canReorder) {
Modifier.longPressDraggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
},
interactionSource = remember { MutableInteractionSource() }
)
} else {
Modifier
}
)
.animateItem(),
name = ui.name,
isEnabled = ui.isEnabled,
isSelected = isSelected,
inSelectionMode = inSelectionMode,
onEnabledChange = { enabled ->
viewModel.update(ui.rule.copy(isEnabled = enabled))
},
onDelete = { showDeleteRuleDialog = ui.rule },
onToTop = { viewModel.toTop(ui.rule) },
onToBottom = { viewModel.toBottom(ui.rule) },
onToggleSelection = {
viewModel.toggleSelection(ui.id)
},
onClickEdit = {
context.startActivity(
ReplaceEditActivity.startIntent(context, ui.id)
)
}
)
}
}
}
}
if (inSelectionMode) {
DraggableSelectionHandler(
listState = listState,
rules = rules,
selectedRuleIds = selectedRuleIds,
onSelectionChange = viewModel::setSelection,
haptic = haptic,
modifier = Modifier
.fillMaxHeight()
.width(60.dp)
.align(Alignment.TopStart)
)
}
AnimatedVisibility(
visible = inSelectionMode,
modifier =
Modifier.align(Alignment.BottomCenter).offset(y = -ScreenOffset).zIndex(1f),
enter = slideInVertically { it } + fadeIn(),
exit = slideOutVertically { it } + fadeOut()
) {
SelectionBottomBar(
onSelectAll = {
viewModel.setSelection(rules.map { it.id }.toSet())
},
onSelectInvert = {
val allIds = rules.map { it.id }.toSet()
viewModel.setSelection(allIds - selectedRuleIds)
},
primaryAction = ActionItem(
text = stringResource(R.string.delete),
icon = { Icon(Icons.Default.Delete, null) },
onClick = { showDeleteSelectedDialog = true }
),
secondaryActions = listOf(
ActionItem(
text = stringResource(R.string.enable),
onClick = {
viewModel.enableSelectionByIds(selectedRuleIds)
viewModel.setSelection(emptySet())
}
if (inSelectionMode) {
DraggableSelectionHandler(
listState = listState,
rules = rules,
selectedRuleIds = selectedRuleIds,
onSelectionChange = viewModel::setSelection,
haptic = haptic,
modifier = Modifier
.fillMaxHeight()
.width(60.dp)
.align(Alignment.TopStart)
)
}
AnimatedVisibility(
visible = inSelectionMode,
modifier =
Modifier.align(Alignment.BottomCenter).offset(y = -ScreenOffset).zIndex(1f),
enter = slideInVertically { it } + fadeIn(),
exit = slideOutVertically { it } + fadeOut()
) {
SelectionBottomBar(
onSelectAll = {
viewModel.setSelection(rules.map { it.id }.toSet())
},
onSelectInvert = {
val allIds = rules.map { it.id }.toSet()
viewModel.setSelection(allIds - selectedRuleIds)
},
primaryAction = ActionItem(
text = stringResource(R.string.delete),
icon = { Icon(Icons.Default.Delete, null) },
onClick = { showDeleteSelectedDialog = true }
),
ActionItem(
text = stringResource(R.string.disable_selection),
onClick = {
viewModel.disableSelectionByIds(selectedRuleIds)
viewModel.setSelection(emptySet())
}
),
ActionItem(
text = stringResource(R.string.to_top),
onClick = {
viewModel.topSelectByIds(selectedRuleIds)
viewModel.setSelection(emptySet())
}
),
ActionItem(
text = stringResource(R.string.to_bottom),
onClick = {
viewModel.bottomSelectByIds(selectedRuleIds)
viewModel.setSelection(emptySet())
}
),
ActionItem(
text = stringResource(R.string.export),
onClick = { showFilePickerSheet = true }
secondaryActions = listOf(
ActionItem(
text = stringResource(R.string.enable),
onClick = {
viewModel.enableSelectionByIds(selectedRuleIds)
viewModel.setSelection(emptySet())
}
),
ActionItem(
text = stringResource(R.string.disable_selection),
onClick = {
viewModel.disableSelectionByIds(selectedRuleIds)
viewModel.setSelection(emptySet())
}
),
ActionItem(
text = stringResource(R.string.to_top),
onClick = {
viewModel.topSelectByIds(selectedRuleIds)
viewModel.setSelection(emptySet())
}
),
ActionItem(
text = stringResource(R.string.to_bottom),
onClick = {
viewModel.bottomSelectByIds(selectedRuleIds)
viewModel.setSelection(emptySet())
}
),
ActionItem(
text = stringResource(R.string.export),
onClick = { showFilePickerSheet = true }
)
)
)
)
}
}
}
}
@@ -1,34 +1,30 @@
package io.legado.app.ui.widget.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -101,65 +97,82 @@ fun SettingItem(
description: String? = null,
option: String? = null,
trailingContent: @Composable (() -> Unit)? = null,
dropdownMenu: (@Composable (onDismiss: () -> Unit) -> Unit)? = null,
onClick: () -> Unit,
onLongClick: (() -> Unit)? = null,
) {
ListItem(
modifier = modifier
.clip(RoundedCornerShape(4.dp))
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick
),
leadingContent = {
when {
painter != null -> {
Icon(
var showMenu by remember { mutableStateOf(false) }
Box ( modifier = Modifier.fillMaxWidth() ){
ListItem(
modifier = modifier
.clip(RoundedCornerShape(4.dp))
.combinedClickable(
onClick = onClick,
onLongClick = {
if (dropdownMenu != null) {
showMenu = true
onLongClick?.invoke()
}
}
),
leadingContent = {
when {
painter != null -> Icon(
painter = painter,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
imageVector != null -> {
Icon(
imageVector != null -> Icon(
imageVector = imageVector,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
},
headlineContent = {
Text(
text = title,
style = MaterialTheme.typography.titleMedium
},
headlineContent = {
Text(
text = title,
style = MaterialTheme.typography.titleMedium
)
},
supportingContent = {
Column(
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
option?.let {
Text(
text = it,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
},
trailingContent = trailingContent,
colors = ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
)
},
supportingContent = {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
)
option?.let {
AnimatedTextLine(
text = it,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
if (dropdownMenu != null) {
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
dropdownMenu {
showMenu = false
}
}
},
trailingContent = trailingContent,
colors = ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
)
)
}
}
}
}
@@ -0,0 +1,31 @@
package io.legado.app.utils.eventBus
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
object FlowEventBus {
// 核心存储结构:Tag -> SharedFlow
private val events = ConcurrentHashMap<String, MutableSharedFlow<Any>>()
// 获取或创建对应 Tag 的 Flow
@Suppress("UNCHECKED_CAST")
fun <T> with(tag: String): MutableSharedFlow<T> {
return events.getOrPut(tag) {
MutableSharedFlow<Any>(
replay = 1, // 相当于 LiveData 的粘性,确保新订阅者能收到最后一次通知
extraBufferCapacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
} as MutableSharedFlow<T>
}
// 快捷发送
fun post(tag: String, value: Any) {
MainScope().launch {
with<Any>(tag).emit(value)
}
}
}
@@ -0,0 +1,22 @@
package io.legado.app.utils.eventBus
import io.legado.app.utils.eventBus.FlowEventBus.with
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class FlowEventBusExt {
// 在协程中直接调用
suspend fun <T> post(tag: String, value: T) {
with<T>(tag).emit(value)
}
// 在非协程环境调用
fun <T> postSync(tag: String, value: T) {
CoroutineScope(Dispatchers.Main).launch {
with<T>(tag).emit(value)
}
}
}