mirror of
https://github.com/amir1376/ab-download-manager.git
synced 2025-02-20 11:43:24 +08:00
add edit download option
This commit is contained in:
parent
ea8aa101f9
commit
5147ef9243
@ -7,6 +7,7 @@ import com.abdownloadmanager.desktop.pages.addDownload.single.AddSingleDownloadC
|
||||
import com.abdownloadmanager.desktop.pages.batchdownload.BatchDownloadComponent
|
||||
import com.abdownloadmanager.desktop.pages.category.CategoryComponent
|
||||
import com.abdownloadmanager.desktop.pages.category.CategoryDialogManager
|
||||
import com.abdownloadmanager.desktop.pages.editdownload.EditDownloadComponent
|
||||
import com.abdownloadmanager.desktop.pages.home.HomeComponent
|
||||
import com.abdownloadmanager.desktop.pages.queue.QueuesComponent
|
||||
import com.abdownloadmanager.desktop.pages.settings.SettingsComponent
|
||||
@ -41,9 +42,11 @@ import com.abdownloadmanager.resources.*
|
||||
import com.abdownloadmanager.utils.category.CategoryManager
|
||||
import com.abdownloadmanager.utils.category.CategorySelectionMode
|
||||
import ir.amirab.downloader.exception.TooManyErrorException
|
||||
import ir.amirab.downloader.monitor.isDownloadActiveFlow
|
||||
import ir.amirab.util.compose.StringSource
|
||||
import ir.amirab.util.compose.asStringSource
|
||||
import ir.amirab.util.compose.combineStringSources
|
||||
import ir.amirab.util.flow.mapStateFlow
|
||||
import ir.amirab.util.osfileutil.FileUtils
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
@ -70,6 +73,7 @@ class AppComponent(
|
||||
DownloadDialogManager,
|
||||
AddDownloadDialogManager,
|
||||
CategoryDialogManager,
|
||||
EditDownloadDialogManager,
|
||||
NotificationSender,
|
||||
DownloadItemOpener,
|
||||
ContainsEffects<AppEffects> by supportEffects(),
|
||||
@ -112,6 +116,7 @@ class AppComponent(
|
||||
addDownloadDialogManager = this,
|
||||
categoryDialogManager = this,
|
||||
notificationSender = this,
|
||||
editDownloadDialogManager = this,
|
||||
)
|
||||
}
|
||||
).subscribeAsStateFlow()
|
||||
@ -150,6 +155,43 @@ class AppComponent(
|
||||
}
|
||||
).subscribeAsStateFlow()
|
||||
|
||||
private val editDownload = SlotNavigation<Long>()
|
||||
val editDownloadSlot = childSlot(
|
||||
editDownload,
|
||||
serializer = null,
|
||||
key = "editDownload",
|
||||
childFactory = { editDownloadConfig: Long, componentContext: ComponentContext ->
|
||||
EditDownloadComponent(
|
||||
ctx = componentContext,
|
||||
onRequestClose = {
|
||||
closeEditDownloadDialog()
|
||||
},
|
||||
onEdited = {
|
||||
scope.launch {
|
||||
downloadSystem.editDownload(it)
|
||||
closeEditDownloadDialog()
|
||||
}
|
||||
},
|
||||
downloadId = editDownloadConfig,
|
||||
acceptEdit = downloadSystem.downloadMonitor
|
||||
.isDownloadActiveFlow(editDownloadConfig)
|
||||
.mapStateFlow { !it },
|
||||
)
|
||||
}
|
||||
).subscribeAsStateFlow()
|
||||
|
||||
override fun openEditDownloadDialog(id: Long) {
|
||||
val currentComponent = editDownloadSlot.value.child?.instance
|
||||
if (currentComponent != null && currentComponent.downloadId == id) {
|
||||
currentComponent.bringToFront()
|
||||
} else {
|
||||
editDownload.activate(id)
|
||||
}
|
||||
}
|
||||
|
||||
override fun closeEditDownloadDialog() {
|
||||
editDownload.dismiss()
|
||||
}
|
||||
|
||||
fun openSettings() {
|
||||
scope.launch {
|
||||
@ -546,6 +588,18 @@ class AppComponent(
|
||||
}
|
||||
}
|
||||
|
||||
fun externalCredentialComingIntoApp(list: List<DownloadCredentials>) {
|
||||
val editDownloadComponent = editDownloadSlot.value.child?.instance
|
||||
if (editDownloadComponent != null) {
|
||||
list.firstOrNull()?.let {
|
||||
editDownloadComponent.importCredential(it)
|
||||
editDownloadComponent.bringToFront()
|
||||
}
|
||||
} else {
|
||||
openAddDownloadDialog(list)
|
||||
}
|
||||
}
|
||||
|
||||
override fun openAddDownloadDialog(
|
||||
links: List<DownloadCredentials>,
|
||||
) {
|
||||
@ -776,6 +830,11 @@ interface DownloadDialogManager {
|
||||
fun closeDownloadDialog(id: Long)
|
||||
}
|
||||
|
||||
interface EditDownloadDialogManager {
|
||||
fun openEditDownloadDialog(id: Long)
|
||||
fun closeEditDownloadDialog()
|
||||
}
|
||||
|
||||
interface AddDownloadDialogManager {
|
||||
val openedAddDownloadDialogs: StateFlow<List<AddDownloadComponent>>
|
||||
fun openAddDownloadDialog(
|
||||
|
@ -10,7 +10,7 @@ import org.koin.core.component.inject
|
||||
class IntegrationHandlerImp: IntegrationHandler,KoinComponent{
|
||||
val appComponent by inject<AppComponent>()
|
||||
override suspend fun addDownload(list: List<NewDownloadInfoFromIntegration>) {
|
||||
appComponent.openAddDownloadDialog(list.map {
|
||||
appComponent.externalCredentialComingIntoApp(list.map {
|
||||
DownloadCredentials(
|
||||
link = it.link,
|
||||
headers = it.headers,
|
||||
|
@ -1,6 +1,5 @@
|
||||
package com.abdownloadmanager.desktop.pages.addDownload.shared
|
||||
|
||||
import com.abdownloadmanager.desktop.pages.addDownload.single.AddSingleDownloadComponent
|
||||
import com.abdownloadmanager.desktop.pages.settings.configurable.widgets.RenderConfigurable
|
||||
import com.abdownloadmanager.desktop.ui.customwindow.BaseOptionDialog
|
||||
import com.abdownloadmanager.desktop.ui.theme.myColors
|
||||
@ -22,11 +21,15 @@ import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.rememberDialogState
|
||||
import com.abdownloadmanager.desktop.pages.settings.configurable.Configurable
|
||||
import java.awt.Dimension
|
||||
import java.awt.MouseInfo
|
||||
|
||||
@Composable
|
||||
fun ExtraConfig(component: AddSingleDownloadComponent) {
|
||||
fun ExtraConfig(
|
||||
onDismiss: () -> Unit,
|
||||
configurables: List<Configurable<*>>,
|
||||
) {
|
||||
val h = 250
|
||||
val w = 300
|
||||
val state = rememberDialogState(
|
||||
@ -35,9 +38,7 @@ fun ExtraConfig(component: AddSingleDownloadComponent) {
|
||||
width = w.dp,
|
||||
),
|
||||
)
|
||||
BaseOptionDialog({
|
||||
component.showMoreSettings = false
|
||||
}, state) {
|
||||
BaseOptionDialog(onDismiss, state) {
|
||||
LaunchedEffect(window){
|
||||
window.moveSafe(
|
||||
MouseInfo.getPointerInfo().location.run {
|
||||
@ -84,7 +85,6 @@ fun ExtraConfig(component: AddSingleDownloadComponent) {
|
||||
Column(
|
||||
Modifier.verticalScroll(scrollState)
|
||||
) {
|
||||
val configurables = component.configurables
|
||||
for ((index, cfg) in configurables.withIndex()) {
|
||||
RenderConfigurable(
|
||||
cfg,
|
||||
|
@ -33,8 +33,6 @@ import androidx.compose.ui.window.*
|
||||
import com.abdownloadmanager.desktop.pages.addDownload.shared.*
|
||||
import com.abdownloadmanager.desktop.utils.mvi.HandleEffects
|
||||
import com.abdownloadmanager.resources.Res
|
||||
import com.abdownloadmanager.resources.*
|
||||
import com.abdownloadmanager.utils.category.rememberIconPainter
|
||||
import ir.amirab.util.compose.resources.myStringResource
|
||||
import ir.amirab.downloader.utils.OnDuplicateStrategy
|
||||
import ir.amirab.util.compose.asStringSource
|
||||
@ -182,7 +180,10 @@ fun AddDownloadPage(
|
||||
)
|
||||
}
|
||||
if (component.showMoreSettings) {
|
||||
ExtraConfig(component)
|
||||
ExtraConfig(
|
||||
onDismiss = { component.showMoreSettings = false },
|
||||
configurables = component.configurables,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,600 @@
|
||||
package com.abdownloadmanager.desktop.pages.editdownload
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
import com.abdownloadmanager.utils.compose.WithContentAlpha
|
||||
import ir.amirab.util.compose.IconSource
|
||||
import com.abdownloadmanager.utils.compose.widget.MyIcon
|
||||
import com.abdownloadmanager.desktop.ui.icon.MyIcons
|
||||
import com.abdownloadmanager.desktop.ui.theme.myColors
|
||||
import com.abdownloadmanager.desktop.ui.theme.myTextSizes
|
||||
import com.abdownloadmanager.desktop.ui.widget.*
|
||||
import com.abdownloadmanager.desktop.utils.*
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.input.pointer.PointerIcon
|
||||
import androidx.compose.ui.input.pointer.pointerHoverIcon
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.*
|
||||
import androidx.compose.ui.window.*
|
||||
import com.abdownloadmanager.desktop.pages.addDownload.shared.ExtraConfig
|
||||
import com.abdownloadmanager.desktop.ui.customwindow.CustomWindow
|
||||
import com.abdownloadmanager.desktop.ui.customwindow.WindowTitle
|
||||
import com.abdownloadmanager.desktop.ui.util.ifThen
|
||||
import com.abdownloadmanager.desktop.utils.mvi.HandleEffects
|
||||
import com.abdownloadmanager.resources.Res
|
||||
import com.abdownloadmanager.utils.FileIconProvider
|
||||
import com.abdownloadmanager.utils.compose.WithContentColor
|
||||
import ir.amirab.util.UrlUtils
|
||||
import ir.amirab.util.compose.resources.myStringResource
|
||||
import ir.amirab.util.compose.asStringSource
|
||||
|
||||
@Composable
|
||||
fun EditDownloadWindow(
|
||||
component: EditDownloadComponent,
|
||||
) {
|
||||
CustomWindow(
|
||||
state = rememberWindowState(
|
||||
size = DpSize(450.dp, 230.dp),
|
||||
position = WindowPosition.Aligned(Alignment.Center)
|
||||
),
|
||||
alwaysOnTop = true,
|
||||
onCloseRequest = {
|
||||
component.onRequestClose()
|
||||
},
|
||||
) {
|
||||
HandleEffects(component) {
|
||||
when (it) {
|
||||
EditDownloadPageEffects.BringToFront -> {
|
||||
window.toFront()
|
||||
}
|
||||
}
|
||||
}
|
||||
EditDownloadPage(component)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditDownloadPage(
|
||||
component: EditDownloadComponent,
|
||||
) {
|
||||
WindowTitle(myStringResource(Res.string.edit_download_title))
|
||||
component.editDownloadUiChecker.collectAsState().value?.let { editDownloadUiChecker ->
|
||||
Column(
|
||||
Modifier
|
||||
.padding(horizontal = 32.dp)
|
||||
.padding(top = 8.dp, bottom = 16.dp)
|
||||
) {
|
||||
val canAddResult by editDownloadUiChecker.canEditDownloadResult.collectAsState()
|
||||
val link by editDownloadUiChecker.link.collectAsState()
|
||||
fun setLink(link: String) {
|
||||
editDownloadUiChecker.setLink(link)
|
||||
}
|
||||
|
||||
val linkFocus = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
linkFocus.requestFocus()
|
||||
}
|
||||
|
||||
UrlTextField(
|
||||
text = link,
|
||||
setText = {
|
||||
setLink(it)
|
||||
},
|
||||
modifier = Modifier.focusRequester(linkFocus),
|
||||
errorText = when (canAddResult) {
|
||||
CanEditDownloadResult.InvalidURL -> Res.string.invalid_url
|
||||
else -> null
|
||||
}?.takeIf { link.isNotEmpty() }?.asStringSource()?.rememberString()
|
||||
// ATTENTION DO NOT use composable functions in when branches
|
||||
// it seems buggy (compose won't render ui properly)
|
||||
// stranger part is that in this case if we use ? before takeIf then it will work! (`}.takeIf {` is buggy but `}?.takeIf {` works!)
|
||||
// maybe there is a bug in compose compiler, or maybe I'm missed something. if you read this ,and you know why! please let me know!
|
||||
)
|
||||
Row {
|
||||
Column(Modifier.weight(1f)) {
|
||||
val name by editDownloadUiChecker.name.collectAsState()
|
||||
Spacer(Modifier.size(8.dp))
|
||||
NameTextField(
|
||||
text = name,
|
||||
setText = {
|
||||
editDownloadUiChecker.setName(it)
|
||||
},
|
||||
errorText = when (canAddResult) {
|
||||
CanEditDownloadResult.FileNameAlreadyExists -> Res.string.file_name_already_exists
|
||||
CanEditDownloadResult.InvalidFileName -> Res.string.invalid_file_name
|
||||
else -> null
|
||||
}?.takeIf { name.isNotEmpty() }?.asStringSource()?.rememberString()
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
BrowserImportButton(component, editDownloadUiChecker)
|
||||
}
|
||||
Spacer(Modifier.size(24.dp))
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Top)
|
||||
.width(IntrinsicSize.Max)
|
||||
) {
|
||||
RenderFileTypeAndSize(component.iconProvider, editDownloadUiChecker)
|
||||
RenderResumeSupport(editDownloadUiChecker)
|
||||
ConfigActionsButtons(editDownloadUiChecker)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
MainActionButtons(component, editDownloadUiChecker)
|
||||
if (editDownloadUiChecker.showMoreSettings.collectAsState().value) {
|
||||
ExtraConfig(
|
||||
onDismiss = {
|
||||
editDownloadUiChecker.setShowMoreSettings(false)
|
||||
},
|
||||
configurables = editDownloadUiChecker.configurables,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BrowserImportButton(
|
||||
component: EditDownloadComponent,
|
||||
downloadUiState: EditDownloadState,
|
||||
) {
|
||||
val credentialsImportedFromExternal by component.credentialsImportedFromExternal.collectAsState()
|
||||
val downloadPage = downloadUiState.currentDownloadItem.collectAsState().value.downloadPage
|
||||
Column {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
ActionButton(
|
||||
myStringResource(Res.string.edit_download_update_from_download_page),
|
||||
enabled = downloadPage != null,
|
||||
onClick = {
|
||||
downloadPage?.let {
|
||||
UrlUtils.openUrl(it)
|
||||
}
|
||||
},
|
||||
// borderColor = when (credentialsImportedFromExternal) {
|
||||
// true -> SolidColor(myColors.success)
|
||||
// false -> SolidColor(myColors.onBackground / 10)
|
||||
// },
|
||||
contentPadding = PaddingValues(
|
||||
vertical = 6.dp,
|
||||
horizontal = animateDpAsState(
|
||||
if (credentialsImportedFromExternal) 8.dp
|
||||
else 16.dp
|
||||
).value,
|
||||
),
|
||||
end = {
|
||||
AnimatedVisibility(credentialsImportedFromExternal) {
|
||||
Row {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
MyIcon(
|
||||
MyIcons.check,
|
||||
null,
|
||||
Modifier.size(16.dp),
|
||||
tint = myColors.success,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Help(myStringResource(Res.string.edit_download_update_from_download_page_description))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderResumeSupport(
|
||||
editDownloadUiChecker: EditDownloadState,
|
||||
) {
|
||||
val fileInfo by editDownloadUiChecker.responseInfo.collectAsState()
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.height(16.dp)
|
||||
) {
|
||||
val lineModifier = Modifier.weight(1f)
|
||||
.height(1.dp)
|
||||
.background(myColors.onBackground / 10)
|
||||
Box(lineModifier)
|
||||
val canEditDownload by editDownloadUiChecker.canEdit.collectAsState()
|
||||
AnimatedVisibility(
|
||||
visible = canEditDownload && fileInfo != null,
|
||||
) {
|
||||
fileInfo?.let { fileInfo ->
|
||||
if (fileInfo.resumeSupport) {
|
||||
val iconModifier = Modifier
|
||||
.padding(horizontal = 2.dp)
|
||||
.size(10.dp)
|
||||
if (fileInfo.resumeSupport) {
|
||||
MyIcon(
|
||||
icon = MyIcons.check,
|
||||
contentDescription = null,
|
||||
modifier = iconModifier,
|
||||
tint = myColors.success
|
||||
)
|
||||
} else {
|
||||
MyIcon(
|
||||
icon = MyIcons.clear,
|
||||
contentDescription = null,
|
||||
modifier = iconModifier,
|
||||
tint = myColors.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(lineModifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainConfigActionButton(
|
||||
text: String,
|
||||
modifier: Modifier,
|
||||
enabled: Boolean = true,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
ActionButton(text, modifier, enabled, onClick)
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun PrimaryMainConfigActionButton(
|
||||
text: String,
|
||||
modifier: Modifier,
|
||||
enabled: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val backgroundColor = Brush.horizontalGradient(
|
||||
myColors.primaryGradientColors.map {
|
||||
it / 30
|
||||
}
|
||||
)
|
||||
val borderColor = Brush.horizontalGradient(
|
||||
myColors.primaryGradientColors
|
||||
)
|
||||
val disabledBorderColor = Brush.horizontalGradient(
|
||||
myColors.primaryGradientColors.map {
|
||||
it / 50
|
||||
}
|
||||
)
|
||||
ActionButton(
|
||||
text = text,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
onClick = onClick,
|
||||
backgroundColor = backgroundColor,
|
||||
disabledBackgroundColor = backgroundColor,
|
||||
borderColor = borderColor,
|
||||
disabledBorderColor = disabledBorderColor,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConfigActionsButtons(
|
||||
editDownloadUiChecker: EditDownloadState,
|
||||
) {
|
||||
val showMoreSettings by editDownloadUiChecker.showMoreSettings.collectAsState()
|
||||
val requiresAuth = editDownloadUiChecker.responseInfo.collectAsState().value?.requireBasicAuth ?: false
|
||||
Row {
|
||||
IconActionButton(MyIcons.refresh, myStringResource(Res.string.refresh)) {
|
||||
editDownloadUiChecker.refresh()
|
||||
}
|
||||
Spacer(Modifier.width(6.dp))
|
||||
IconActionButton(
|
||||
MyIcons.settings,
|
||||
myStringResource(Res.string.settings),
|
||||
indicateActive = showMoreSettings,
|
||||
requiresAttention = requiresAuth
|
||||
) {
|
||||
editDownloadUiChecker.setShowMoreSettings(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainActionButtons(
|
||||
component: EditDownloadComponent,
|
||||
editDownloadUiChecker: EditDownloadState,
|
||||
) {
|
||||
Row {
|
||||
val canEditResult by editDownloadUiChecker.canEditDownloadResult.collectAsState()
|
||||
|
||||
val canEdit = run {
|
||||
val canBeEdited = editDownloadUiChecker.canEdit.collectAsState().value
|
||||
val componentAllowsEdit = component.acceptEdit.collectAsState().value
|
||||
canBeEdited && componentAllowsEdit
|
||||
}
|
||||
val warnings = (canEditResult as? CanEditDownloadResult.CanEdit)?.warnings.orEmpty()
|
||||
Spacer(Modifier.width(8.dp))
|
||||
var showWarningPrompt by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
Box {
|
||||
if (showWarningPrompt) {
|
||||
WarningPrompt(
|
||||
warnings = warnings,
|
||||
onClose = {
|
||||
showWarningPrompt = false
|
||||
},
|
||||
onConfirm = {
|
||||
if (canEdit) {
|
||||
component.onRequestEdit()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
PrimaryMainConfigActionButton(
|
||||
text = myStringResource(Res.string.change),
|
||||
modifier = Modifier,
|
||||
enabled = canEdit,
|
||||
onClick = {
|
||||
if (warnings.isNotEmpty()) {
|
||||
showWarningPrompt = true
|
||||
} else {
|
||||
component.onRequestEdit()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
// Spacer(Modifier.weight(1f))
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
MainConfigActionButton(
|
||||
text = myStringResource(Res.string.cancel),
|
||||
modifier = Modifier,
|
||||
onClick = {
|
||||
component.onRequestClose()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WarningPrompt(
|
||||
warnings: List<CanEditWarnings>,
|
||||
onClose: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
) {
|
||||
Popup(
|
||||
popupPositionProvider = rememberComponentRectPositionProvider(
|
||||
anchor = Alignment.TopStart,
|
||||
alignment = Alignment.TopEnd,
|
||||
),
|
||||
onDismissRequest = onClose
|
||||
) {
|
||||
val shape = RoundedCornerShape(6.dp)
|
||||
Box(
|
||||
Modifier
|
||||
.padding(vertical = 4.dp)
|
||||
.widthIn(max = 240.dp)
|
||||
.shadow(24.dp)
|
||||
.clip(shape)
|
||||
.border(1.dp, myColors.surface, shape)
|
||||
.background(myColors.menuGradientBackground)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
WithContentColor(myColors.onSurface) {
|
||||
Column {
|
||||
Text(
|
||||
myStringResource(Res.string.warning),
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = myColors.warning
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
warnings.forEach {
|
||||
Text(
|
||||
it.asStringSource().rememberString(),
|
||||
fontSize = myTextSizes.base,
|
||||
)
|
||||
}
|
||||
Text(myStringResource(Res.string.warning_you_may_have_to_restart_the_download_later))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
ActionButton(
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
text = myStringResource(Res.string.change_anyway),
|
||||
onClick = onConfirm,
|
||||
borderColor = SolidColor(myColors.error),
|
||||
contentColor = myColors.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderFileTypeAndSize(
|
||||
iconProvider: FileIconProvider,
|
||||
editDownloadUiChecker: EditDownloadState,
|
||||
) {
|
||||
val isLinkLoading by editDownloadUiChecker.isLinkLoading.collectAsState()
|
||||
val fileInfo by editDownloadUiChecker.responseInfo.collectAsState()
|
||||
val iconModifier = Modifier.size(16.dp)
|
||||
Box(Modifier.padding(top = 16.dp)) {
|
||||
AnimatedContent(
|
||||
targetState = isLinkLoading,
|
||||
transitionSpec = {
|
||||
fadeIn() togetherWith fadeOut()
|
||||
}
|
||||
) { loading ->
|
||||
if (loading) {
|
||||
LoadingIndicator(iconModifier)
|
||||
} else {
|
||||
val icon = iconProvider.rememberIcon(editDownloadUiChecker.name.collectAsState().value)
|
||||
AnimatedContent(
|
||||
fileInfo,
|
||||
) { fileInfo ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
WithContentAlpha(1f) {
|
||||
if (fileInfo != null) {
|
||||
if (fileInfo.requiresAuth) {
|
||||
MyIcon(
|
||||
MyIcons.lock,
|
||||
null,
|
||||
iconModifier,
|
||||
tint = myColors.error
|
||||
)
|
||||
}
|
||||
MyIcon(
|
||||
icon,
|
||||
null,
|
||||
iconModifier
|
||||
)
|
||||
val size = fileInfo.totalLength?.let {
|
||||
convertSizeToHumanReadable(it)
|
||||
}.takeIf {
|
||||
// this is a length of a html page (error)
|
||||
fileInfo.isSuccessFul
|
||||
} ?: Res.string.unknown.asStringSource()
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
size.rememberString(),
|
||||
fontSize = myTextSizes.sm,
|
||||
)
|
||||
} else {
|
||||
MyIcon(
|
||||
icon = MyIcons.question,
|
||||
contentDescription = null,
|
||||
modifier = iconModifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MyTextFieldIcon(
|
||||
icon: IconSource,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
MyIcon(icon, null, Modifier
|
||||
.fillMaxHeight()
|
||||
.ifThen(onClick != null) {
|
||||
pointerHoverIcon(PointerIcon.Default)
|
||||
.clickable { onClick?.invoke() }
|
||||
}
|
||||
.wrapContentHeight()
|
||||
.padding(horizontal = 8.dp)
|
||||
.size(16.dp))
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun UrlTextField(
|
||||
text: String,
|
||||
setText: (String) -> Unit,
|
||||
errorText: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AddDownloadPageTextField(
|
||||
text,
|
||||
setText,
|
||||
myStringResource(Res.string.download_link),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
start = {
|
||||
MyTextFieldIcon(MyIcons.link)
|
||||
},
|
||||
end = {
|
||||
MyTextFieldIcon(MyIcons.paste) {
|
||||
setText(
|
||||
ClipboardUtil.read()
|
||||
.orEmpty()
|
||||
)
|
||||
}
|
||||
},
|
||||
errorText = errorText
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NameTextField(
|
||||
text: String,
|
||||
setText: (String) -> Unit,
|
||||
errorText: String? = null,
|
||||
) {
|
||||
AddDownloadPageTextField(
|
||||
text,
|
||||
setText,
|
||||
myStringResource(Res.string.name),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
errorText = errorText,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddDownloadPageTextField(
|
||||
text: String,
|
||||
setText: (String) -> Unit,
|
||||
placeHolder: String,
|
||||
modifier: Modifier,
|
||||
errorText: String? = null,
|
||||
start: @Composable() (() -> Unit)? = null,
|
||||
end: @Composable() (() -> Unit)? = null,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isFocused by interactionSource.collectIsFocusedAsState()
|
||||
val dividerModifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(vertical = 1.dp)
|
||||
//to not conflict with text-field border
|
||||
.width(1.dp)
|
||||
.background(if (isFocused) myColors.onBackground / 10 else Color.Transparent)
|
||||
Column(modifier) {
|
||||
MyTextField(
|
||||
text,
|
||||
setText,
|
||||
placeHolder,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
background = myColors.surface / 50,
|
||||
interactionSource = interactionSource,
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
start = start?.let {
|
||||
{
|
||||
WithContentAlpha(0.5f) {
|
||||
it()
|
||||
}
|
||||
Spacer(dividerModifier)
|
||||
}
|
||||
},
|
||||
end = end?.let {
|
||||
{
|
||||
Spacer(dividerModifier)
|
||||
it()
|
||||
}
|
||||
}
|
||||
)
|
||||
AnimatedVisibility(errorText != null) {
|
||||
if (errorText != null) {
|
||||
Text(
|
||||
errorText,
|
||||
Modifier.padding(bottom = 4.dp, start = 4.dp),
|
||||
fontSize = myTextSizes.sm,
|
||||
color = myColors.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,100 @@
|
||||
package com.abdownloadmanager.desktop.pages.editdownload
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.abdownloadmanager.desktop.utils.*
|
||||
import com.abdownloadmanager.desktop.utils.mvi.ContainsEffects
|
||||
import com.abdownloadmanager.desktop.utils.mvi.ContainsScreenState
|
||||
import com.abdownloadmanager.desktop.utils.mvi.SupportsScreenState
|
||||
import com.abdownloadmanager.desktop.utils.mvi.supportEffects
|
||||
import com.abdownloadmanager.utils.FileIconProvider
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import ir.amirab.downloader.connection.DownloaderClient
|
||||
import ir.amirab.downloader.downloaditem.DownloadCredentials
|
||||
import ir.amirab.downloader.downloaditem.DownloadItem
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
sealed interface EditDownloadPageEffects {
|
||||
data object BringToFront : EditDownloadPageEffects
|
||||
}
|
||||
|
||||
class EditDownloadComponent(
|
||||
ctx: ComponentContext,
|
||||
val onRequestClose: () -> Unit,
|
||||
val downloadId: Long,
|
||||
val acceptEdit: StateFlow<Boolean>,
|
||||
private val onEdited: (DownloadItem) -> Unit,
|
||||
) : BaseComponent(ctx),
|
||||
ContainsEffects<EditDownloadPageEffects> by supportEffects(),
|
||||
KoinComponent {
|
||||
private val downloaderClient: DownloaderClient by inject()
|
||||
val iconProvider: FileIconProvider by inject()
|
||||
val downloadSystem: DownloadSystem by inject()
|
||||
val editDownloadUiChecker = MutableStateFlow(null as EditDownloadState?)
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
load(downloadId)
|
||||
}
|
||||
}
|
||||
|
||||
private var pendingCredential: DownloadCredentials? = null
|
||||
private val _credentialsImportedFromExternal = MutableStateFlow(false)
|
||||
val credentialsImportedFromExternal = _credentialsImportedFromExternal.asStateFlow()
|
||||
fun importCredential(credentials: DownloadCredentials) {
|
||||
editDownloadUiChecker.value?.let {
|
||||
it.importCredentials(credentials)
|
||||
} ?: run {
|
||||
pendingCredential = credentials
|
||||
}
|
||||
_credentialsImportedFromExternal.value = true
|
||||
}
|
||||
|
||||
private suspend fun load(id: Long) {
|
||||
val downloadItem = downloadSystem.getDownloadItemById(id = id)
|
||||
if (downloadItem == null) {
|
||||
onRequestClose()
|
||||
println("item with id $id not found")
|
||||
return
|
||||
}
|
||||
val editDownloadState = EditDownloadState(
|
||||
currentDownloadItem = MutableStateFlow(downloadItem),
|
||||
editedDownloadItem = MutableStateFlow(downloadItem),
|
||||
downloaderClient = downloaderClient,
|
||||
conflictDetector = object : DownloadConflictDetector {
|
||||
override fun checkAlreadyExists(current: DownloadItem, edited: DownloadItem): Boolean {
|
||||
val editedDownloadFile = downloadSystem.getDownloadFile(edited)
|
||||
val alreadyExists = editedDownloadFile.exists()
|
||||
if (alreadyExists) {
|
||||
return true
|
||||
}
|
||||
return downloadSystem
|
||||
.getAllRegisteredDownloadFiles()
|
||||
.contains(editedDownloadFile)
|
||||
}
|
||||
},
|
||||
scope,
|
||||
)
|
||||
editDownloadUiChecker.value = editDownloadState
|
||||
pendingCredential?.let { credentials ->
|
||||
editDownloadState.importCredentials(credentials)
|
||||
pendingCredential = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun onRequestEdit() {
|
||||
if (!acceptEdit.value) {
|
||||
return
|
||||
}
|
||||
editDownloadUiChecker.value?.let { editDownloadUiChecker ->
|
||||
onEdited(editDownloadUiChecker.editedDownloadItem.value)
|
||||
}
|
||||
}
|
||||
|
||||
fun bringToFront() {
|
||||
sendEffect(EditDownloadPageEffects.BringToFront)
|
||||
}
|
||||
}
|
@ -0,0 +1,331 @@
|
||||
package com.abdownloadmanager.desktop.pages.editdownload
|
||||
|
||||
import com.abdownloadmanager.desktop.pages.settings.configurable.IntConfigurable
|
||||
import com.abdownloadmanager.desktop.pages.settings.configurable.SpeedLimitConfigurable
|
||||
import com.abdownloadmanager.desktop.pages.settings.configurable.StringConfigurable
|
||||
import com.abdownloadmanager.desktop.utils.FileNameValidator
|
||||
import com.abdownloadmanager.desktop.utils.LinkChecker
|
||||
import com.abdownloadmanager.desktop.utils.convertSpeedToHumanReadable
|
||||
import com.abdownloadmanager.resources.Res
|
||||
import com.abdownloadmanager.utils.isValidUrl
|
||||
import ir.amirab.downloader.connection.DownloaderClient
|
||||
import ir.amirab.downloader.downloaditem.DownloadCredentials
|
||||
import ir.amirab.downloader.downloaditem.DownloadItem
|
||||
import ir.amirab.downloader.downloaditem.DownloadItem.Companion.LENGTH_UNKNOWN
|
||||
import ir.amirab.downloader.downloaditem.withCredentials
|
||||
import ir.amirab.util.compose.StringSource
|
||||
import ir.amirab.util.compose.asStringSource
|
||||
import ir.amirab.util.compose.asStringSourceWithARgs
|
||||
import ir.amirab.util.flow.createMutableStateFlowFromStateFlow
|
||||
import ir.amirab.util.flow.mapStateFlow
|
||||
import ir.amirab.util.flow.mapTwoWayStateFlow
|
||||
import ir.amirab.util.flow.onEachLatest
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
||||
sealed interface CanEditWarnings {
|
||||
fun asStringSource(): StringSource
|
||||
data class FileSizeNotMatch(
|
||||
val currentSize: Long,
|
||||
val newSize: Long,
|
||||
) : CanEditWarnings {
|
||||
override fun asStringSource(): StringSource {
|
||||
return "The saved item have size of $currentSize and now will change to $newSize".asStringSource()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface CanEditDownloadResult {
|
||||
data object FileNameAlreadyExists : CanEditDownloadResult
|
||||
data object InvalidURL : CanEditDownloadResult
|
||||
data object InvalidFileName : CanEditDownloadResult
|
||||
data object NothingChanged : CanEditDownloadResult
|
||||
data object Waiting : CanEditDownloadResult
|
||||
data class CanEdit(
|
||||
val warnings: List<CanEditWarnings>,
|
||||
) : CanEditDownloadResult
|
||||
}
|
||||
|
||||
class EditDownloadChecker(
|
||||
val currentDownloadItem: MutableStateFlow<DownloadItem>,
|
||||
val editedDownloadItem: MutableStateFlow<DownloadItem>,
|
||||
val newLengthFlow: StateFlow<Long?>,
|
||||
val conflictDetector: DownloadConflictDetector,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
init {
|
||||
editedDownloadItem
|
||||
.onEach {
|
||||
_canEditResult.value = CanEditDownloadResult.Waiting
|
||||
}.launchIn(scope)
|
||||
}
|
||||
|
||||
fun check() {
|
||||
_canEditResult.value = CanEditDownloadResult.Waiting
|
||||
_canEditResult.value = check(
|
||||
current = currentDownloadItem.value,
|
||||
edited = editedDownloadItem.value,
|
||||
newLength = newLengthFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
private fun check(
|
||||
current: DownloadItem,
|
||||
edited: DownloadItem,
|
||||
newLength: Long?,
|
||||
): CanEditDownloadResult {
|
||||
if (current == edited) {
|
||||
return CanEditDownloadResult.NothingChanged
|
||||
}
|
||||
if (!isValidUrl(edited.link)) {
|
||||
return CanEditDownloadResult.InvalidURL
|
||||
}
|
||||
if (edited.name != current.name) {
|
||||
if (!FileNameValidator.isValidFileName(edited.name)) {
|
||||
return CanEditDownloadResult.InvalidFileName
|
||||
}
|
||||
if (conflictDetector.checkAlreadyExists(current, edited)) {
|
||||
return CanEditDownloadResult.FileNameAlreadyExists
|
||||
}
|
||||
}
|
||||
val warnings = mutableListOf<CanEditWarnings>()
|
||||
if (current.contentLength != newLength) {
|
||||
warnings.add(
|
||||
CanEditWarnings.FileSizeNotMatch(
|
||||
currentSize = current.contentLength,
|
||||
newSize = newLength ?: LENGTH_UNKNOWN,
|
||||
)
|
||||
)
|
||||
}
|
||||
return CanEditDownloadResult.CanEdit(warnings)
|
||||
}
|
||||
|
||||
private val _canEditResult = MutableStateFlow<CanEditDownloadResult>(CanEditDownloadResult.NothingChanged)
|
||||
val canEditResult = _canEditResult.asStateFlow()
|
||||
val canEdit = canEditResult.mapStateFlow {
|
||||
it is CanEditDownloadResult.CanEdit
|
||||
}
|
||||
}
|
||||
|
||||
interface DownloadConflictDetector {
|
||||
fun checkAlreadyExists(
|
||||
current: DownloadItem,
|
||||
edited: DownloadItem,
|
||||
): Boolean
|
||||
}
|
||||
|
||||
class EditDownloadState(
|
||||
val currentDownloadItem: MutableStateFlow<DownloadItem>,
|
||||
val editedDownloadItem: MutableStateFlow<DownloadItem>,
|
||||
val downloaderClient: DownloaderClient,
|
||||
conflictDetector: DownloadConflictDetector,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
private val _showMoreSettings = MutableStateFlow(false)
|
||||
val showMoreSettings = _showMoreSettings.asStateFlow()
|
||||
fun setShowMoreSettings(showMoreSettings: Boolean) {
|
||||
_showMoreSettings.value = showMoreSettings
|
||||
}
|
||||
|
||||
val credentials = editedDownloadItem.mapTwoWayStateFlow(
|
||||
map = {
|
||||
DownloadCredentials.from(it)
|
||||
},
|
||||
unMap = {
|
||||
copy().withCredentials(it)
|
||||
}
|
||||
)
|
||||
val name = editedDownloadItem.mapTwoWayStateFlow(
|
||||
map = {
|
||||
it.name
|
||||
},
|
||||
unMap = {
|
||||
copy(name = it)
|
||||
}
|
||||
)
|
||||
val configurables = listOf(
|
||||
SpeedLimitConfigurable(
|
||||
Res.string.download_item_settings_speed_limit.asStringSource(),
|
||||
Res.string.download_item_settings_speed_limit_description.asStringSource(),
|
||||
backedBy = editedDownloadItem.mapTwoWayStateFlow(
|
||||
map = {
|
||||
it.speedLimit
|
||||
},
|
||||
unMap = {
|
||||
copy(speedLimit = it)
|
||||
}
|
||||
),
|
||||
describe = {
|
||||
if (it == 0L) Res.string.unlimited.asStringSource()
|
||||
else convertSpeedToHumanReadable(it).asStringSource()
|
||||
}
|
||||
),
|
||||
IntConfigurable(
|
||||
Res.string.settings_download_thread_count.asStringSource(),
|
||||
Res.string.settings_download_thread_count_description.asStringSource(),
|
||||
backedBy = editedDownloadItem.mapTwoWayStateFlow(
|
||||
map = {
|
||||
it.preferredConnectionCount ?: 0
|
||||
},
|
||||
unMap = {
|
||||
copy(
|
||||
preferredConnectionCount = it.takeIf { it > 1 }
|
||||
)
|
||||
}
|
||||
),
|
||||
range = 0..32,
|
||||
describe = {
|
||||
if (it == 0) Res.string.use_global_settings.asStringSource()
|
||||
else Res.string.download_item_settings_thread_count_describe
|
||||
.asStringSourceWithARgs(
|
||||
Res.string.download_item_settings_thread_count_describe_createArgs(
|
||||
count = it.toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
),
|
||||
StringConfigurable(
|
||||
Res.string.username.asStringSource(),
|
||||
Res.string.download_item_settings_username_description.asStringSource(),
|
||||
backedBy = credentials.mapTwoWayStateFlow(
|
||||
map = {
|
||||
it.username.orEmpty()
|
||||
},
|
||||
unMap = {
|
||||
copy(username = it.takeIf { it.isNotEmpty() })
|
||||
}
|
||||
),
|
||||
describe = {
|
||||
"".asStringSource()
|
||||
}
|
||||
),
|
||||
StringConfigurable(
|
||||
Res.string.password.asStringSource(),
|
||||
Res.string.download_item_settings_password_description.asStringSource(),
|
||||
backedBy = credentials.mapTwoWayStateFlow(
|
||||
map = {
|
||||
it.password.orEmpty()
|
||||
},
|
||||
unMap = {
|
||||
copy(password = it.takeIf { it.isNotEmpty() })
|
||||
}
|
||||
),
|
||||
describe = {
|
||||
"".asStringSource()
|
||||
}
|
||||
),
|
||||
StringConfigurable(
|
||||
Res.string.download_item_settings_download_page.asStringSource(),
|
||||
Res.string.download_item_settings_download_page_description.asStringSource(),
|
||||
backedBy = credentials.mapTwoWayStateFlow(
|
||||
map = {
|
||||
it.downloadPage.orEmpty()
|
||||
},
|
||||
unMap = {
|
||||
copy(downloadPage = it.takeIf { it.isNotEmpty() })
|
||||
}
|
||||
),
|
||||
describe = {
|
||||
"".asStringSource()
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
fun setName(name: String) {
|
||||
this.name.value = name
|
||||
}
|
||||
|
||||
val link = credentials.mapTwoWayStateFlow(
|
||||
map = { it.link },
|
||||
unMap = {
|
||||
copy(link = it)
|
||||
}
|
||||
)
|
||||
|
||||
fun setLink(link: String) {
|
||||
credentials.update {
|
||||
it.copy(link = link)
|
||||
}
|
||||
}
|
||||
|
||||
fun importCredentials(importedCredentials: DownloadCredentials) {
|
||||
this.credentials.update {
|
||||
importedCredentials
|
||||
}
|
||||
}
|
||||
|
||||
private val linkChecker = LinkChecker(
|
||||
initialCredentials = credentials.value,
|
||||
client = downloaderClient,
|
||||
)
|
||||
val isLinkLoading = linkChecker.isLoading
|
||||
|
||||
val gettingResponseInfo = linkChecker.isLoading
|
||||
val responseInfo = linkChecker.responseInfo
|
||||
val length = linkChecker.length
|
||||
|
||||
private val editDownloadChecker = EditDownloadChecker(
|
||||
currentDownloadItem = currentDownloadItem,
|
||||
editedDownloadItem = editedDownloadItem,
|
||||
newLengthFlow = length,
|
||||
conflictDetector = conflictDetector,
|
||||
scope = scope,
|
||||
)
|
||||
|
||||
val canEditDownloadResult = editDownloadChecker.canEditResult
|
||||
val canEdit = editDownloadChecker.canEdit
|
||||
|
||||
private val refreshResponseInfoImmediately = MutableSharedFlow<Unit>(
|
||||
replay = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_LATEST
|
||||
)
|
||||
private val scheduleRefreshResponseInfo = MutableSharedFlow<Unit>(
|
||||
replay = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_LATEST
|
||||
)
|
||||
private val scheduleRecheckEditDownloadIsPossible = MutableSharedFlow<Unit>(
|
||||
replay = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_LATEST
|
||||
)
|
||||
|
||||
fun refresh() {
|
||||
refreshResponseInfoImmediately.tryEmit(Unit)
|
||||
}
|
||||
|
||||
private fun scheduleRefresh(
|
||||
alsoRecheckLink: Boolean,
|
||||
) {
|
||||
if (alsoRecheckLink) {
|
||||
scheduleRefreshResponseInfo.tryEmit(Unit)
|
||||
}
|
||||
scheduleRecheckEditDownloadIsPossible.tryEmit(Unit)
|
||||
}
|
||||
|
||||
init {
|
||||
merge(
|
||||
scheduleRefreshResponseInfo.debounce(500),
|
||||
refreshResponseInfoImmediately
|
||||
).onEachLatest {
|
||||
linkChecker.check()
|
||||
}.launchIn(scope)
|
||||
merge(
|
||||
scheduleRecheckEditDownloadIsPossible.debounce(500),
|
||||
// ...
|
||||
).onEachLatest {
|
||||
editDownloadChecker.check()
|
||||
}.launchIn(scope)
|
||||
|
||||
credentials.onEach { credentials ->
|
||||
linkChecker.credentials.update { credentials }
|
||||
scheduleRefresh(alsoRecheckLink = true)
|
||||
}.launchIn(scope)
|
||||
editedDownloadItem.onEach {
|
||||
scheduleRefresh(alsoRecheckLink = false)
|
||||
}.launchIn(scope)
|
||||
length.onEach {
|
||||
scheduleRefresh(alsoRecheckLink = false)
|
||||
}.launchIn(scope)
|
||||
}
|
||||
}
|
@ -76,6 +76,7 @@ class DownloadActions(
|
||||
private val scope: CoroutineScope,
|
||||
downloadSystem: DownloadSystem,
|
||||
downloadDialogManager: DownloadDialogManager,
|
||||
editDownloadDialogManager: EditDownloadDialogManager,
|
||||
val selections: StateFlow<List<IDownloadItemState>>,
|
||||
private val mainItem: StateFlow<Long?>,
|
||||
private val queueManager: QueueManager,
|
||||
@ -192,6 +193,20 @@ class DownloadActions(
|
||||
}
|
||||
}
|
||||
)
|
||||
val editDownloadAction = simpleAction(
|
||||
title = Res.string.edit.asStringSource(),
|
||||
icon = MyIcons.edit,
|
||||
checkEnable = defaultItem.mapStateFlow {
|
||||
it ?: return@mapStateFlow false
|
||||
it.statusOrFinished() !is DownloadJobStatus.IsActive
|
||||
},
|
||||
onActionPerformed = {
|
||||
scope.launch {
|
||||
val item = defaultItem.value ?: return@launch
|
||||
editDownloadDialogManager.openEditDownloadDialog(item.id)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
val copyDownloadLinkAction = simpleAction(
|
||||
title = Res.string.copy_link.asStringSource(),
|
||||
@ -268,6 +283,7 @@ class DownloadActions(
|
||||
+moveToCategoryAction
|
||||
separator()
|
||||
+(copyDownloadLinkAction)
|
||||
+editDownloadAction
|
||||
+(openDownloadDialogAction)
|
||||
}
|
||||
}
|
||||
@ -381,6 +397,7 @@ class HomeComponent(
|
||||
ctx: ComponentContext,
|
||||
private val downloadItemOpener: DownloadItemOpener,
|
||||
private val downloadDialogManager: DownloadDialogManager,
|
||||
private val editDownloadDialogManager: EditDownloadDialogManager,
|
||||
private val addDownloadDialogManager: AddDownloadDialogManager,
|
||||
private val categoryDialogManager: CategoryDialogManager,
|
||||
private val notificationSender: NotificationSender,
|
||||
@ -826,6 +843,7 @@ class HomeComponent(
|
||||
scope = scope,
|
||||
downloadSystem = downloadSystem,
|
||||
downloadDialogManager = downloadDialogManager,
|
||||
editDownloadDialogManager = editDownloadDialogManager,
|
||||
selections = selectionListItems,
|
||||
mainItem = mainItem,
|
||||
queueManager = queueManager,
|
||||
@ -882,6 +900,7 @@ class HomeComponent(
|
||||
"DELETE" to downloadActions.deleteAction
|
||||
"ctrl O" to downloadActions.openFileAction
|
||||
"ctrl F" to downloadActions.openFolderAction
|
||||
"ctrl E" to downloadActions.editDownloadAction
|
||||
"ctrl P" to downloadActions.pauseAction
|
||||
"ctrl R" to downloadActions.resumeAction
|
||||
"DELETE" to downloadActions.deleteAction
|
||||
|
@ -29,6 +29,7 @@ import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.window.*
|
||||
import com.abdownloadmanager.desktop.pages.batchdownload.BatchDownloadWindow
|
||||
import com.abdownloadmanager.desktop.pages.category.ShowCategoryDialogs
|
||||
import com.abdownloadmanager.desktop.pages.editdownload.EditDownloadWindow
|
||||
import com.abdownloadmanager.desktop.pages.home.HomeWindow
|
||||
import com.abdownloadmanager.desktop.pages.settings.ThemeManager
|
||||
import com.abdownloadmanager.desktop.ui.widget.ProvideLanguageManager
|
||||
@ -84,6 +85,10 @@ object Ui : KoinComponent {
|
||||
batchDownloadSlot.child?.instance?.let {
|
||||
BatchDownloadWindow(it)
|
||||
}
|
||||
val editDownloadSlot = appComponent.editDownloadSlot.collectAsState().value
|
||||
editDownloadSlot.child?.instance?.let {
|
||||
EditDownloadWindow(it)
|
||||
}
|
||||
ShowAddDownloadDialogs(appComponent)
|
||||
ShowDownloadDialogs(appComponent)
|
||||
ShowCategoryDialogs(appComponent)
|
||||
|
@ -20,6 +20,7 @@ object MyIcons : IMyIcons {
|
||||
override val windowClose get() = "/icons/window_close.svg".asIconSource()
|
||||
|
||||
override val exit get() = "/icons/exit.svg".asIconSource()
|
||||
override val edit get() = "/icons/edit.svg".asIconSource()
|
||||
override val undo get() = "/icons/undo.svg".asIconSource()
|
||||
|
||||
override val openSource: IconSource get() = "/icons/open_source.svg".asIconSource()
|
||||
|
@ -9,8 +9,7 @@ import com.abdownloadmanager.desktop.utils.div
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@ -24,35 +23,44 @@ import androidx.compose.ui.unit.dp
|
||||
@Composable
|
||||
fun ActionButton(
|
||||
text: String,
|
||||
modifier: Modifier=Modifier,
|
||||
enabled: Boolean=true,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
onClick: () -> Unit,
|
||||
backgroundColor: Brush = SolidColor(myColors.surface),
|
||||
disabledBackgroundColor: Brush = SolidColor(myColors.surface / 0.5f),
|
||||
borderColor: Brush = SolidColor(myColors.onBackground / 10),
|
||||
disabledBorderColor: Brush = SolidColor(myColors.onBackground / 10),
|
||||
contentColor: Color = LocalContentColor.current,
|
||||
contentPadding: PaddingValues = PaddingValues(vertical = 6.dp, horizontal = 24.dp),
|
||||
start: (@Composable RowScope.() -> Unit)? = null,
|
||||
end: (@Composable RowScope.() -> Unit)? = null,
|
||||
) {
|
||||
val shape = RoundedCornerShape(10.dp)
|
||||
Box(
|
||||
Row(
|
||||
modifier
|
||||
.border(1.dp, if (enabled)borderColor else disabledBorderColor, shape)
|
||||
.border(1.dp, if (enabled) borderColor else disabledBorderColor, shape)
|
||||
.clip(shape)
|
||||
.background(if (enabled) backgroundColor else disabledBackgroundColor)
|
||||
.clickable(enabled = enabled) {
|
||||
onClick()
|
||||
}
|
||||
.padding(vertical = 6.dp, horizontal = 24.dp)
|
||||
.padding(contentPadding)
|
||||
) {
|
||||
WithContentColor(contentColor){
|
||||
WithContentColor(contentColor) {
|
||||
WithContentAlpha(if (enabled) 1f else 0.5f) {
|
||||
start?.let {
|
||||
it()
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
modifier = Modifier,
|
||||
fontSize = myTextSizes.base,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
)
|
||||
end?.let {
|
||||
it()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -5,15 +5,14 @@ import com.abdownloadmanager.utils.category.CategoryManager
|
||||
import com.abdownloadmanager.utils.category.CategorySelectionMode
|
||||
import ir.amirab.downloader.DownloadManager
|
||||
import ir.amirab.downloader.db.IDownloadListDb
|
||||
import ir.amirab.downloader.downloaditem.DownloadItem
|
||||
import ir.amirab.downloader.downloaditem.DownloadItemContext
|
||||
import ir.amirab.downloader.downloaditem.DownloadStatus
|
||||
import ir.amirab.downloader.downloaditem.EmptyContext
|
||||
import ir.amirab.downloader.downloaditem.*
|
||||
import ir.amirab.downloader.downloaditem.contexts.RemovedBy
|
||||
import ir.amirab.downloader.downloaditem.contexts.ResumedBy
|
||||
import ir.amirab.downloader.downloaditem.contexts.StoppedBy
|
||||
import ir.amirab.downloader.downloaditem.contexts.User
|
||||
import ir.amirab.downloader.monitor.IDownloadMonitor
|
||||
import ir.amirab.downloader.monitor.isDownloadActiveFlow
|
||||
import ir.amirab.downloader.monitor.statusOrFinished
|
||||
import ir.amirab.downloader.queue.QueueManager
|
||||
import ir.amirab.downloader.utils.OnDuplicateStrategy
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@ -223,4 +222,35 @@ class DownloadSystem(
|
||||
it.id
|
||||
}
|
||||
}
|
||||
fun getAllRegisteredDownloadFiles(): List<File> {
|
||||
return downloadMonitor.run {
|
||||
activeDownloadListFlow.value + completedDownloadListFlow.value
|
||||
}.map {
|
||||
File(it.folder, it.name)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun isDownloadActive(id: Long): Boolean {
|
||||
return downloadMonitor.isDownloadActiveFlow(id).value
|
||||
}
|
||||
|
||||
suspend fun editDownload(updatedItem: DownloadItem) {
|
||||
val wasActive = isDownloadActive(updatedItem.id)
|
||||
if (wasActive) {
|
||||
manualPause(updatedItem.id)
|
||||
}
|
||||
downloadManager.updateDownloadItem(updatedItem.id) { currentItem ->
|
||||
var shouldUpdate = true
|
||||
if (currentItem.folder == updatedItem.folder && currentItem.name != updatedItem.name) {
|
||||
val success = getDownloadFile(currentItem).renameTo(getDownloadFile(updatedItem))
|
||||
shouldUpdate = success
|
||||
}
|
||||
if (shouldUpdate) {
|
||||
currentItem.applyFrom(updatedItem)
|
||||
}
|
||||
}
|
||||
if (wasActive) {
|
||||
manualResume(updatedItem.id)
|
||||
}
|
||||
}
|
||||
}
|
@ -4,13 +4,14 @@ import ir.amirab.downloader.connection.DownloaderClient
|
||||
import ir.amirab.downloader.connection.response.ResponseInfo
|
||||
import ir.amirab.downloader.downloaditem.DownloadCredentials
|
||||
import com.abdownloadmanager.utils.isValidUrl
|
||||
import ir.amirab.downloader.downloaditem.IDownloadCredentials
|
||||
import ir.amirab.util.UrlUtils
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
class LinkChecker(
|
||||
initialCredentials: DownloadCredentials = DownloadCredentials.empty(),
|
||||
initialCredentials: IDownloadCredentials = DownloadCredentials.empty(),
|
||||
private val client: DownloaderClient,
|
||||
) {
|
||||
//input
|
||||
|
4
desktop/app/src/main/resources/icons/edit.svg
Normal file
4
desktop/app/src/main/resources/icons/edit.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 17.46V20.5C3 20.78 3.22 21 3.5 21H6.54C6.67 21 6.8 20.95 6.89 20.85L17.81 9.94L14.06 6.19L3.15 17.1C3.05 17.2 3 17.32 3 17.46ZM20.71 7.04C20.8027 6.94749 20.8762 6.8376 20.9264 6.71663C20.9766 6.59565 21.0024 6.46597 21.0024 6.335C21.0024 6.20403 20.9766 6.07435 20.9264 5.95338C20.8762 5.83241 20.8027 5.72252 20.71 5.63L18.37 3.29C18.2775 3.1973 18.1676 3.12375 18.0466 3.07357C17.9257 3.02339 17.796 2.99756 17.665 2.99756C17.534 2.99756 17.4043 3.02339 17.2834 3.07357C17.1624 3.12375 17.0525 3.1973 16.96 3.29L15.13 5.12L18.88 8.87L20.71 7.04Z"
|
||||
fill="white"/>
|
||||
</svg>
|
After Width: | Height: | Size: 694 B |
@ -15,6 +15,21 @@ data class DownloadCredentials(
|
||||
fun empty()=DownloadCredentials(
|
||||
link = ""
|
||||
)
|
||||
fun from(credentials: IDownloadCredentials): DownloadCredentials {
|
||||
credentials.run {
|
||||
return when (this) {
|
||||
is DownloadCredentials -> this
|
||||
else -> DownloadCredentials(
|
||||
link = link,
|
||||
headers = headers,
|
||||
username = username,
|
||||
password = password,
|
||||
downloadPage = downloadPage,
|
||||
userAgent = userAgent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,16 +41,3 @@ interface IDownloadCredentials {
|
||||
val downloadPage: String?
|
||||
val userAgent: String?
|
||||
}
|
||||
fun IDownloadCredentials.copy(): DownloadCredentials {
|
||||
return when (this) {
|
||||
is DownloadCredentials -> this
|
||||
else -> DownloadCredentials(
|
||||
link = link,
|
||||
headers = headers,
|
||||
username = username,
|
||||
password = password,
|
||||
downloadPage = downloadPage,
|
||||
userAgent = userAgent,
|
||||
)
|
||||
}
|
||||
}
|
@ -16,7 +16,7 @@ data class DownloadItem(
|
||||
var name: String,
|
||||
|
||||
var contentLength: Long = LENGTH_UNKNOWN,
|
||||
var serverETag:String? = null,
|
||||
var serverETag: String? = null,
|
||||
|
||||
var dateAdded: Long = 0,
|
||||
var startTime: Long? = null,
|
||||
@ -30,6 +30,29 @@ data class DownloadItem(
|
||||
}
|
||||
}
|
||||
|
||||
fun DownloadItem.applyFrom(other: DownloadItem) {
|
||||
link = other.link
|
||||
headers = other.headers
|
||||
username = other.username
|
||||
password = other.password
|
||||
downloadPage = other.downloadPage
|
||||
userAgent = other.userAgent
|
||||
|
||||
id = other.id
|
||||
folder = other.folder
|
||||
name = other.name
|
||||
|
||||
contentLength = other.contentLength
|
||||
serverETag = other.serverETag
|
||||
|
||||
dateAdded = other.dateAdded
|
||||
startTime = other.startTime
|
||||
completeTime = other.completeTime
|
||||
status = other.status
|
||||
preferredConnectionCount = other.preferredConnectionCount
|
||||
speedLimit = other.speedLimit
|
||||
}
|
||||
|
||||
fun DownloadItem.withCredentials(credentials: IDownloadCredentials) = apply {
|
||||
link = credentials.link
|
||||
headers = credentials.headers
|
||||
|
@ -52,15 +52,18 @@ class DownloadJob(
|
||||
|
||||
private val _isDownloadActive = MutableStateFlow(false)
|
||||
val isDownloadActive = _isDownloadActive.asStateFlow()
|
||||
private fun initializeDestination() {
|
||||
val outFile = downloadManager.calculateOutputFile(downloadItem)
|
||||
destination = SimpleDownloadDestination(
|
||||
file = outFile,
|
||||
diskStat = downloadManager.diskStat,
|
||||
emptyFileCreator = downloadManager.emptyFileCreator
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun boot() {
|
||||
if (!booted) {
|
||||
val outFile = downloadManager.calculateOutputFile(downloadItem)
|
||||
destination = SimpleDownloadDestination(
|
||||
file = outFile,
|
||||
diskStat = downloadManager.diskStat,
|
||||
emptyFileCreator = downloadManager.emptyFileCreator
|
||||
)
|
||||
initializeDestination()
|
||||
loadPartState()
|
||||
supportsConcurrent = when (getParts().size) {
|
||||
in 2..Int.MAX_VALUE -> true
|
||||
@ -226,6 +229,11 @@ class DownloadJob(
|
||||
suspend fun changeConfig(updater: (DownloadItem) -> Unit): DownloadItem {
|
||||
val last = downloadItem.copy()
|
||||
downloadItem.apply(updater)
|
||||
if (downloadManager.calculateOutputFile(last) != downloadManager.calculateOutputFile(downloadItem)) {
|
||||
pause()
|
||||
// destination should be closed for now!
|
||||
initializeDestination()
|
||||
}
|
||||
if (last.preferredConnectionCount != downloadItem.preferredConnectionCount) {
|
||||
onPreferredConnectionCountChanged()
|
||||
}
|
||||
|
@ -1,5 +1,7 @@
|
||||
package ir.amirab.downloader.monitor
|
||||
|
||||
import ir.amirab.downloader.downloaditem.DownloadJobStatus
|
||||
import ir.amirab.util.flow.mapStateFlow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@ -14,4 +16,16 @@ interface IDownloadMonitor {
|
||||
suspend fun waitForDownloadToFinishOrCancel(
|
||||
id: Long
|
||||
): Boolean
|
||||
}
|
||||
|
||||
fun IDownloadMonitor.isDownloadActiveFlow(
|
||||
downloadId: Long,
|
||||
): StateFlow<Boolean> {
|
||||
return activeDownloadListFlow.mapStateFlow { activeDownloadList ->
|
||||
activeDownloadList.find {
|
||||
downloadId == it.id
|
||||
}?.statusOrFinished()?.let {
|
||||
it is DownloadJobStatus.IsActive
|
||||
} ?: false
|
||||
}
|
||||
}
|
@ -23,6 +23,7 @@ interface IMyIcons {
|
||||
val windowMaximize: IconSource
|
||||
val windowClose: IconSource
|
||||
val exit: IconSource
|
||||
val edit: IconSource
|
||||
val undo: IconSource
|
||||
// val menu: IconSource
|
||||
// val menuClose: IconSource
|
||||
|
@ -20,6 +20,8 @@ close=Close
|
||||
ok=Ok
|
||||
add=Add
|
||||
change=Change
|
||||
edit=Edit
|
||||
change_anyway=Change Anyway
|
||||
download=Download
|
||||
refresh=Refresh
|
||||
settings=Settings
|
||||
@ -99,7 +101,7 @@ group=Group
|
||||
add_download=Add Download
|
||||
add_multi_download_page_header=Select Items you want to pick up for download
|
||||
save_to=Save To
|
||||
where_should_each_item_saved=Where should each item saved?
|
||||
where_should_each_item_saved=Where should each item be saved?
|
||||
there_are_multiple_items_please_select_a_way_you_want_to_save_them=There are multiple items! please select a way you want to save them
|
||||
each_item_on_its_own_category=Each item on its own category
|
||||
each_item_on_its_own_category_description=Each item will be placed in a category that have that file type
|
||||
@ -211,6 +213,8 @@ download_item_settings_thread_count_description=How much thread used to download
|
||||
download_item_settings_thread_count_describe={{count}} threads for this download
|
||||
download_item_settings_username_description=Provide a username if the link is a protected resource
|
||||
download_item_settings_password_description=Provide a password if the link is a protected resource
|
||||
download_item_settings_download_page=Download Page
|
||||
download_item_settings_download_page_description=The webpage where this download was initiated
|
||||
username=Username
|
||||
password=Password
|
||||
average_speed=Average Speed
|
||||
@ -282,3 +286,7 @@ address=Address
|
||||
port=Port
|
||||
address_and_port=Address & Port
|
||||
use_authentication=Use Authentication
|
||||
warning_you_may_have_to_restart_the_download_later=You may have to restart the download later!
|
||||
edit_download_title=Edit Download
|
||||
edit_download_update_from_download_page=Update from Download Page
|
||||
edit_download_update_from_download_page_description=When this window is open, you can go to the Download Page and click the download button. The app will capture and update the new download credentials so you can save them.
|
Loading…
x
Reference in New Issue
Block a user