diff --git a/app/src/main/java/io/legado/app/service/WebService.kt b/app/src/main/java/io/legado/app/service/WebService.kt index a5617a140..9a11e4952 100644 --- a/app/src/main/java/io/legado/app/service/WebService.kt +++ b/app/src/main/java/io/legado/app/service/WebService.kt @@ -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 ?: "") diff --git a/app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt b/app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt index 410512ea0..ceaed6f46 100644 --- a/app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt @@ -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( diff --git a/app/src/main/java/io/legado/app/ui/main/my/MyViewModel.kt b/app/src/main/java/io/legado/app/ui/main/my/MyViewModel.kt index 7f93bebf1..fe28ba081 100644 --- a/app/src/main/java/io/legado/app/ui/main/my/MyViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/main/my/MyViewModel.kt @@ -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 = _uiState.asStateFlow() + init { + viewModelScope.launch { + FlowEventBus.with(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 "" - ) - } } diff --git a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt index d7872a4ad..e705bee5f 100644 --- a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt +++ b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt @@ -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 } + ) ) ) - ) + } } } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/SettingItem.kt b/app/src/main/java/io/legado/app/ui/widget/components/SettingItem.kt index 7aea6540f..7288a9335 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/SettingItem.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/SettingItem.kt @@ -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 - ) - ) -} \ No newline at end of file + } + } +} diff --git a/app/src/main/java/io/legado/app/utils/eventBus/FlowEventBus.kt b/app/src/main/java/io/legado/app/utils/eventBus/FlowEventBus.kt new file mode 100644 index 000000000..bb79808f8 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/eventBus/FlowEventBus.kt @@ -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>() + + // 获取或创建对应 Tag 的 Flow + @Suppress("UNCHECKED_CAST") + fun with(tag: String): MutableSharedFlow { + return events.getOrPut(tag) { + MutableSharedFlow( + replay = 1, // 相当于 LiveData 的粘性,确保新订阅者能收到最后一次通知 + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + } as MutableSharedFlow + } + + // 快捷发送 + fun post(tag: String, value: Any) { + MainScope().launch { + with(tag).emit(value) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/eventBus/FlowEventBusExt.kt b/app/src/main/java/io/legado/app/utils/eventBus/FlowEventBusExt.kt new file mode 100644 index 000000000..2ee118b68 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/eventBus/FlowEventBusExt.kt @@ -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 post(tag: String, value: T) { + with(tag).emit(value) + } + + // 在非协程环境调用 + fun postSync(tag: String, value: T) { + CoroutineScope(Dispatchers.Main).launch { + with(tag).emit(value) + } + } + +} \ No newline at end of file