Merge pull request #206 from amir1376/feature/download-completion-page

add completed download page
This commit is contained in:
AmirHossein Abdolmotallebi 2024-11-21 09:17:40 +03:30 committed by GitHub
commit d95511accb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 434 additions and 172 deletions

View File

@ -0,0 +1,157 @@
package com.abdownloadmanager.desktop.pages.singleDownloadPage
import androidx.compose.foundation.background
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.onClick
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
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.ActionButton
import com.abdownloadmanager.desktop.ui.widget.Text
import com.abdownloadmanager.desktop.utils.convertSizeToHumanReadable
import com.abdownloadmanager.desktop.utils.div
import com.abdownloadmanager.resources.Res
import com.abdownloadmanager.utils.compose.WithContentColor
import com.abdownloadmanager.utils.compose.widget.MyIcon
import ir.amirab.downloader.monitor.CompletedDownloadItemState
import ir.amirab.util.compose.resources.myStringResource
@Composable
fun CompletedDownloadPage(
component: SingleDownloadComponent,
completedDownloadItemState: CompletedDownloadItemState,
) {
Column {
Row(
Modifier
.padding(
horizontal = 16.dp,
vertical = 8.dp
)
) {
RenderFileIconAndSize(
modifier = Modifier.align(Alignment.CenterVertically),
component = component,
itemState = completedDownloadItemState,
)
Spacer(Modifier.width(16.dp))
RenderName(
Modifier.weight(1f),
completedDownloadItemState.name,
)
}
Spacer(Modifier.weight(1f))
Actions(Modifier, component)
}
}
@Composable
private fun Actions(
modifier: Modifier,
component: SingleDownloadComponent,
) {
Column(modifier) {
Spacer(
Modifier
.fillMaxWidth()
.height(1.dp)
.background(myColors.onBackground / 0.15f)
)
Row(
Modifier
.fillMaxWidth()
.background(myColors.surface / 0.5f)
.padding(horizontal = 16.dp)
.padding(vertical = 8.dp),
) {
ActionButton(
myStringResource(Res.string.open),
modifier = Modifier,
onClick = {
component.openFile()
},
)
Spacer(Modifier.width(8.dp))
ActionButton(
myStringResource(Res.string.open_folder),
modifier = Modifier,
onClick = {
component.openFolder()
},
)
Spacer(Modifier.weight(1f))
ActionButton(
myStringResource(Res.string.close),
modifier = Modifier,
onClick = component::close,
)
}
}
}
@Composable
private fun RenderName(
modifier: Modifier,
name: String,
) {
Column(
modifier = modifier
) {
WithContentColor(
myColors.success
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
MyIcon(
MyIcons.check, null,
Modifier.size(24.dp)
)
Spacer(Modifier.width(4.dp))
Text(
myStringResource(Res.string.download_page_download_completed),
fontWeight = FontWeight.Bold,
fontSize = myTextSizes.lg,
)
}
}
Spacer(Modifier.height(8.dp))
Text(
name,
maxLines = 1,
modifier = Modifier.basicMarquee()
)
}
}
@Composable
private fun RenderFileIconAndSize(
modifier: Modifier,
component: SingleDownloadComponent,
itemState: CompletedDownloadItemState,
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
MyIcon(
icon = component.fileIconProvider.rememberIcon(itemState.name),
contentDescription = null,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.height(4.dp))
Text(
text = convertSizeToHumanReadable(itemState.contentLength)
.rememberString(),
)
}
}

View File

@ -10,16 +10,16 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.FrameWindowScope
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.WindowState
import androidx.compose.ui.window.rememberWindowState
import com.abdownloadmanager.resources.Res
import ir.amirab.downloader.downloaditem.DownloadJobStatus
import ir.amirab.downloader.monitor.CompletedDownloadItemState
import ir.amirab.downloader.monitor.IDownloadItemState
import ir.amirab.downloader.monitor.ProcessingDownloadItemState
import ir.amirab.downloader.monitor.statusOrFinished
import ir.amirab.downloader.utils.ExceptionUtils
import ir.amirab.util.compose.resources.myStringResource
import java.awt.Dimension
import java.awt.Taskbar
import java.awt.Window
@ -35,20 +35,21 @@ import java.awt.Window
// }
//}
@Composable
fun getDownloadTitle(itemState: IDownloadItemState): String {
private fun getDownloadTitle(itemState: IDownloadItemState): String {
return buildString {
if (itemState is ProcessingDownloadItemState && itemState.percent != null) {
append("${itemState.percent}%")
append("-")
append(" ")
}
append("${itemState.name}")
append(itemState.name)
}
}
val LocalSingleDownloadPageSizing = compositionLocalOf<SingleDownloadPageSizing> { error("LocalSingleBoxSizing not provided") }
val LocalSingleDownloadPageSizing =
compositionLocalOf<SingleProgressDownloadPageSizing> { error("LocalSingleBoxSizing not provided") }
@Stable
class SingleDownloadPageSizing {
class SingleProgressDownloadPageSizing {
var resizingPartInfo by mutableStateOf(false)
var partInfoHeight by mutableStateOf(150.dp)
}
@ -57,60 +58,145 @@ class SingleDownloadPageSizing {
fun ShowDownloadDialogs(component: DownloadDialogManager) {
val openedDownloadDialogs = component.openedDownloadDialogs.collectAsState().value
for (singleDownloadComponent in openedDownloadDialogs) {
val onRequestClose = {
component.closeDownloadDialog(singleDownloadComponent.downloadId)
}
val defaultHeight = 290f
val defaultWidth = 450f
val showPartInfo by singleDownloadComponent.showPartInfo.collectAsState()
val itemState by singleDownloadComponent.itemStateFlow.collectAsState()
val state = rememberWindowState(
height = defaultHeight.dp,
width = defaultWidth.dp,
position = WindowPosition(Alignment.Center)
)
CustomWindow(
state = state,
onRequestToggleMaximize = null,
resizable = false,
onCloseRequest = onRequestClose,
) {
HandleEffects(singleDownloadComponent) {
when (it) {
SingleDownloadEffects.BringToFront -> {
state.isMinimized = false
window.toFront()
}
itemState?.let {
when (it) {
is CompletedDownloadItemState -> {
CompletedWindow(
singleDownloadComponent,
it,
)
}
is ProcessingDownloadItemState -> {
ProgressWindow(
singleDownloadComponent = singleDownloadComponent,
itemState = it,
)
}
}
LaunchedEffect(Unit) {
window.minimumSize = Dimension(defaultWidth.toInt(), defaultHeight.toInt())
}
val singleDownloadPageSizing = remember(showPartInfo) { SingleDownloadPageSizing() }
WindowTitle(itemState?.let { getDownloadTitle(it) } ?: myStringResource(Res.string.download))
WindowIcon(MyIcons.appIcon)
var h = defaultHeight
var w = defaultWidth
if (showPartInfo && itemState is ProcessingDownloadItemState) {
h += singleDownloadPageSizing.partInfoHeight.value
}
LaunchedEffect(w, h) {
state.size = DpSize(
width = w.dp,
height = h.dp
)
}
itemState?.let { itemState ->
UpdateTaskBar(window, itemState)
}
CompositionLocalProvider(
LocalSingleDownloadPageSizing provides singleDownloadPageSizing
) {
SingleDownloadPage(singleDownloadComponent)
}
}
}
@Composable
private fun FrameWindowScope.CommonContent(
singleDownloadComponent: SingleDownloadComponent,
state: WindowState,
itemState: IDownloadItemState,
) {
HandleEffects(singleDownloadComponent) {
when (it) {
SingleDownloadEffects.BringToFront -> {
state.isMinimized = false
window.toFront()
}
}
}
WindowTitle(getDownloadTitle(itemState))
WindowIcon(MyIcons.appIcon)
UpdateTaskBar(window, itemState)
}
@Composable
private fun CompletedWindow(
singleDownloadComponent: SingleDownloadComponent,
itemState: CompletedDownloadItemState,
) {
val onRequestClose = {
singleDownloadComponent.close()
}
val defaultHeight = 160f
val defaultWidth = 450f
val state = rememberWindowState(
height = defaultHeight.dp,
width = defaultWidth.dp,
position = WindowPosition(Alignment.Center)
)
CustomWindow(
state = state,
onRequestToggleMaximize = null,
resizable = false,
alwaysOnTop = true,
onCloseRequest = onRequestClose,
) {
CommonContent(
singleDownloadComponent = singleDownloadComponent,
state = state,
itemState = itemState,
)
LaunchedEffect(Unit) {
window.minimumSize = Dimension(defaultWidth.toInt(), defaultHeight.toInt())
}
var h = defaultHeight
var w = defaultWidth
LaunchedEffect(w, h) {
state.size = DpSize(
width = w.dp,
height = h.dp
)
}
CompletedDownloadPage(
singleDownloadComponent,
itemState,
)
}
}
@Composable
private fun ProgressWindow(
singleDownloadComponent: SingleDownloadComponent,
itemState: ProcessingDownloadItemState,
) {
val onRequestClose = {
singleDownloadComponent.close()
}
val defaultHeight = 290f
val defaultWidth = 450f
val showPartInfo by singleDownloadComponent.showPartInfo.collectAsState()
val state = rememberWindowState(
height = defaultHeight.dp,
width = defaultWidth.dp,
position = WindowPosition(Alignment.Center)
)
CustomWindow(
state = state,
onRequestToggleMaximize = null,
resizable = false,
onCloseRequest = onRequestClose,
) {
CommonContent(
singleDownloadComponent = singleDownloadComponent,
state = state,
itemState = itemState,
)
LaunchedEffect(Unit) {
window.minimumSize = Dimension(defaultWidth.toInt(), defaultHeight.toInt())
}
val singleDownloadPageSizing = remember(showPartInfo) { SingleProgressDownloadPageSizing() }
var h = defaultHeight
var w = defaultWidth
if (showPartInfo) {
h += singleDownloadPageSizing.partInfoHeight.value
}
LaunchedEffect(w, h) {
state.size = DpSize(
width = w.dp,
height = h.dp
)
}
CompositionLocalProvider(
LocalSingleDownloadPageSizing provides singleDownloadPageSizing
) {
ProgressDownloadPage(
singleDownloadComponent,
itemState,
)
}
}
}
@Composable

View File

@ -65,80 +65,77 @@ enum class SingleDownloadPageSections(
private val tabs = SingleDownloadPageSections.entries.toList()
@Composable
fun SingleDownloadPage(singleDownloadComponent: SingleDownloadComponent) {
val itemState = singleDownloadComponent.itemStateFlow.collectAsState().value
fun ProgressDownloadPage(singleDownloadComponent: SingleDownloadComponent, itemState: ProcessingDownloadItemState) {
var selectedTab by remember { mutableStateOf(Info) }
val showPartInfo by singleDownloadComponent.showPartInfo.collectAsState()
val setShowPartInfo = singleDownloadComponent::setShowPartInfo
if (itemState != null) {
Column(
Modifier.padding(horizontal = 16.dp)
) {
Column(
Modifier.padding(horizontal = 16.dp)
Modifier
.clip(RoundedCornerShape(6.dp))
.background(myColors.surface)
.padding(1.dp),
) {
Column(
Modifier
.clip(RoundedCornerShape(6.dp))
.background(myColors.surface)
.padding(1.dp),
) {
//tabs
MyTabRow {
for (tab in tabs) {
MyTab(
selected = tab == selectedTab, {
selectedTab = tab
},
icon = tab.icon,
title = tab.title
)
}
}
val scrollState = rememberScrollState()
//info / settings ...
val tabContentModifier = Modifier
Box(
Modifier.height(150.dp)
.clip(RoundedCornerShape(bottomStart = 6.dp, bottomEnd = 6.dp))
.background(myColors.background)
.verticalScroll(scrollState)
) {
when (selectedTab) {
Info -> RenderInfo(
tabContentModifier,
singleDownloadComponent
)
Settings -> RenderSettings(
tabContentModifier.padding(end = 12.dp),
singleDownloadComponent,
)
}
VerticalScrollbar(
adapter = rememberScrollbarAdapter(scrollState),
modifier = Modifier.matchParentSize().wrapContentWidth(Alignment.End),
style = LocalScrollbarStyle.current.copy(
shape = RectangleShape
)
//tabs
MyTabRow {
for (tab in tabs) {
MyTab(
selected = tab == selectedTab, {
selectedTab = tab
},
icon = tab.icon,
title = tab.title
)
}
}
Spacer(Modifier.size(8.dp))
Column(Modifier) {
RenderProgressBar(itemState)
Spacer(Modifier.size(8.dp))
RenderActions(itemState, singleDownloadComponent, showPartInfo, setShowPartInfo)
Spacer(Modifier.size(8.dp))
}
val resizingState = LocalSingleDownloadPageSizing.current
LaunchedEffect(resizingState.resizingPartInfo) {
if (resizingState.partInfoHeight <= 0.dp) {
setShowPartInfo(false)
val scrollState = rememberScrollState()
//info / settings ...
val tabContentModifier = Modifier
Box(
Modifier.height(150.dp)
.clip(RoundedCornerShape(bottomStart = 6.dp, bottomEnd = 6.dp))
.background(myColors.background)
.verticalScroll(scrollState)
) {
when (selectedTab) {
Info -> RenderInfo(
tabContentModifier,
singleDownloadComponent
)
Settings -> RenderSettings(
tabContentModifier.padding(end = 12.dp),
singleDownloadComponent,
)
}
VerticalScrollbar(
adapter = rememberScrollbarAdapter(scrollState),
modifier = Modifier.matchParentSize().wrapContentWidth(Alignment.End),
style = LocalScrollbarStyle.current.copy(
shape = RectangleShape
)
)
}
if (showPartInfo && itemState is ProcessingDownloadItemState) {
RenderPartInfo(itemState)
}
Spacer(Modifier.size(8.dp))
Column(Modifier) {
RenderProgressBar(itemState)
Spacer(Modifier.size(8.dp))
RenderActions(itemState, singleDownloadComponent, showPartInfo, setShowPartInfo)
Spacer(Modifier.size(8.dp))
}
val resizingState = LocalSingleDownloadPageSizing.current
LaunchedEffect(resizingState.resizingPartInfo) {
if (resizingState.partInfoHeight <= 0.dp) {
setShowPartInfo(false)
}
}
if (showPartInfo) {
RenderPartInfo(itemState)
}
}
}
@ -485,7 +482,7 @@ fun RenderInfo(
.padding(horizontal = 16.dp)
.padding(top = 8.dp)
) {
for (propertyItem in singleDownloadComponent.extraDownloadInfo.collectAsState().value) {
for (propertyItem in singleDownloadComponent.extraDownloadProgressInfo.collectAsState().value) {
Spacer(Modifier.height(2.dp))
RenderPropertyItem(propertyItem)
}

View File

@ -9,7 +9,7 @@ import com.abdownloadmanager.desktop.utils.mvi.supportEffects
import arrow.optics.copy
import com.abdownloadmanager.desktop.storage.PageStatesStorage
import com.abdownloadmanager.resources.Res
import com.abdownloadmanager.resources.*
import com.abdownloadmanager.utils.FileIconProvider
import com.arkivanov.decompose.ComponentContext
import ir.amirab.downloader.DownloadManagerEvents
import ir.amirab.downloader.downloaditem.DownloadJobStatus
@ -19,6 +19,7 @@ import ir.amirab.downloader.monitor.*
import ir.amirab.util.compose.StringSource
import ir.amirab.util.compose.asStringSource
import ir.amirab.util.compose.asStringSourceWithARgs
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@ -42,20 +43,44 @@ data class SingleDownloadPagePropertyItem(
class SingleDownloadComponent(
ctx: ComponentContext,
val downloadItemOpener: DownloadItemOpener,
val onDismiss: () -> Unit,
private val onDismiss: () -> Unit,
val downloadId: Long,
) : BaseComponent(ctx),
ContainsEffects<SingleDownloadEffects> by supportEffects(),
KoinComponent {
private val downloadSystem: DownloadSystem by inject()
val fileIconProvider: FileIconProvider by inject()
private val singleDownloadPageStateToPersist by lazy {
get<PageStatesStorage>().downloadPage
}
private val downloadMonitor: IDownloadMonitor = downloadSystem.downloadMonitor
private val downloadManager: DownloadManager = downloadSystem.downloadManager
val itemStateFlow = downloadMonitor.downloadListFlow.map {
it.firstOrNull { it.id == downloadId }
}.stateIn(scope, SharingStarted.Eagerly, null)
val itemStateFlow = MutableStateFlow<IDownloadItemState?>(null)
private fun shouldShowCompletionDialog(): Boolean {
// TODO implement an option to allow user disable this
return true
}
init {
downloadMonitor
.downloadListFlow
.conflate()
.onEach {
val item = it.firstOrNull { it.id == downloadId }
val previous = itemStateFlow.value
if (previous is ProcessingDownloadItemState && item is CompletedDownloadItemState) {
// if It was opened to show progress
if (shouldShowCompletionDialog()) {
itemStateFlow.value = item
} else {
close()
}
} else {
itemStateFlow.value = item
}
}.launchIn(scope)
}
private val _showPartInfo = MutableStateFlow(singleDownloadPageStateToPersist.value.showPartInfo)
val showPartInfo = _showPartInfo.asStateFlow()
@ -68,8 +93,9 @@ class SingleDownloadComponent(
}
}
val extraDownloadInfo: StateFlow<List<SingleDownloadPagePropertyItem>> = itemStateFlow
.filterNotNull()
// TODO this can be moved to a nested component to reduce system resource usage
val extraDownloadProgressInfo: StateFlow<List<SingleDownloadPagePropertyItem>> = itemStateFlow
.filterIsInstance<ProcessingDownloadItemState>()
.map {
buildList {
add(SingleDownloadPagePropertyItem(Res.string.name.asStringSource(), it.name.asStringSource()))
@ -80,48 +106,41 @@ class SingleDownloadComponent(
convertSizeToHumanReadable(it.contentLength)
)
)
when (it) {
is CompletedDownloadItemState -> {
}
is ProcessingDownloadItemState -> {
add(
SingleDownloadPagePropertyItem(
Res.string.download_page_downloaded_size.asStringSource(),
convertBytesToHumanReadable(it.progress).orEmpty().asStringSource()
)
)
add(
SingleDownloadPagePropertyItem(
Res.string.speed.asStringSource(),
convertSpeedToHumanReadable(it.speed).asStringSource()
)
)
add(
SingleDownloadPagePropertyItem(
Res.string.time_left.asStringSource(),
(it.remainingTime?.let { remainingTime ->
add(
SingleDownloadPagePropertyItem(
Res.string.download_page_downloaded_size.asStringSource(),
convertBytesToHumanReadable(it.progress).orEmpty().asStringSource()
)
)
add(
SingleDownloadPagePropertyItem(
Res.string.speed.asStringSource(),
convertSpeedToHumanReadable(it.speed).asStringSource()
)
)
add(
SingleDownloadPagePropertyItem(
Res.string.time_left.asStringSource(),
(it.remainingTime?.let { remainingTime ->
convertTimeRemainingToHumanReadable(remainingTime, TimeNames.ShortNames)
}.orEmpty()).asStringSource()
)
)
add(
SingleDownloadPagePropertyItem(
Res.string.resume_support.asStringSource(),
when (it.supportResume) {
true -> Res.string.yes.asStringSource()
false -> Res.string.no.asStringSource()
null -> Res.string.unknown.asStringSource()
},
when (it.supportResume) {
true -> SingleDownloadPagePropertyItem.ValueType.Success
false -> SingleDownloadPagePropertyItem.ValueType.Error
null -> SingleDownloadPagePropertyItem.ValueType.Normal
}
)
)
}
}
}.orEmpty()).asStringSource()
)
)
add(
SingleDownloadPagePropertyItem(
Res.string.resume_support.asStringSource(),
when (it.supportResume) {
true -> Res.string.yes.asStringSource()
false -> Res.string.no.asStringSource()
null -> Res.string.unknown.asStringSource()
},
when (it.supportResume) {
true -> SingleDownloadPagePropertyItem.ValueType.Success
false -> SingleDownloadPagePropertyItem.ValueType.Error
null -> SingleDownloadPagePropertyItem.ValueType.Normal
}
)
)
}
}.stateIn(scope, SharingStarted.Eagerly, emptyList())
@ -154,7 +173,7 @@ class SingleDownloadComponent(
}
}
fun openFile() {
fun openFile(alsoClose: Boolean = true) {
val itemState = itemStateFlow.value
scope.launch {
if (itemState is CompletedDownloadItemState) {
@ -162,12 +181,14 @@ class SingleDownloadComponent(
downloadItemOpener.openDownloadItem(downloadId)
}
}
onDismiss()
if (alsoClose) {
onDismiss()
}
}
}
fun toggle() {
val state = itemStateFlow.value as ProcessingDownloadItemState ?: return
val state = itemStateFlow.value as? ProcessingDownloadItemState ?: return
scope.launch {
if (state.status is DownloadJobStatus.IsActive) {
downloadSystem.manualPause(downloadId)
@ -178,7 +199,7 @@ class SingleDownloadComponent(
}
fun resume() {
val state = itemStateFlow.value as ProcessingDownloadItemState ?: return
val state = itemStateFlow.value as? ProcessingDownloadItemState ?: return
scope.launch {
if (state.status is DownloadJobStatus.CanBeResumed) {
downloadSystem.manualResume(downloadId)
@ -187,7 +208,7 @@ class SingleDownloadComponent(
}
fun pause() {
val state = itemStateFlow.value as ProcessingDownloadItemState ?: return
val state = itemStateFlow.value as? ProcessingDownloadItemState ?: return
scope.launch {
if (state.status is DownloadJobStatus.IsActive) {
downloadSystem.manualPause(downloadId)

View File

@ -54,7 +54,7 @@ val darkColors = MyColors(
surface = Color(0xFF22222A),
error = Color(0xffff5757),
onError = Color.White,
success = Color(0xff14a600),
success = Color(0xff69BA5A),
onSuccess = Color.White,
warning = Color(0xFFffbe56),
onWarning = Color.White,

View File

@ -254,6 +254,7 @@ time_left=Time Left
date_added=Date Added
info=Info
download_page_downloaded_size=Downloaded
download_page_download_completed=Download Completed
resume_support=Resume Support
yes=Yes
no=No