Merge pull request #107 from amir1376/feature/custom-categories

add custom categories
This commit is contained in:
AmirHossein Abdolmotallebi 2024-10-15 17:55:48 +03:30 committed by GitHub
commit 5613f6c2c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 3223 additions and 295 deletions

View File

@ -5,6 +5,8 @@ import com.abdownloadmanager.desktop.pages.addDownload.AddDownloadConfig
import com.abdownloadmanager.desktop.pages.addDownload.multiple.AddMultiDownloadComponent
import com.abdownloadmanager.desktop.pages.addDownload.single.AddSingleDownloadComponent
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.home.HomeComponent
import com.abdownloadmanager.desktop.pages.queue.QueuesComponent
import com.abdownloadmanager.desktop.pages.settings.SettingsComponent
@ -35,6 +37,8 @@ import ir.amirab.downloader.utils.ExceptionUtils
import ir.amirab.downloader.utils.OnDuplicateStrategy
import com.abdownloadmanager.integration.Integration
import com.abdownloadmanager.integration.IntegrationResult
import com.abdownloadmanager.utils.category.CategoryManager
import com.abdownloadmanager.utils.category.CategorySelectionMode
import ir.amirab.downloader.exception.TooManyErrorException
import ir.amirab.util.osfileutil.FileUtils
import kotlinx.coroutines.flow.*
@ -61,6 +65,7 @@ class AppComponent(
) : BaseComponent(ctx),
DownloadDialogManager,
AddDownloadDialogManager,
CategoryDialogManager,
NotificationSender,
DownloadItemOpener,
ContainsEffects<AppEffects> by supportEffects(),
@ -101,7 +106,8 @@ class AppComponent(
downloadItemOpener = this,
downloadDialogManager = this,
addDownloadDialogManager = this,
notificationSender = this
categoryDialogManager = this,
notificationSender = this,
)
}
).subscribeAsStateFlow()
@ -192,12 +198,25 @@ class AppComponent(
onRequestClose = {
closeAddDownloadDialog(config.id)
},
onRequestAddToQueue = { item, queueId, onDuplicate ->
addDownload(item = item, queueId = queueId, onDuplicateStrategy = onDuplicate)
onRequestAddToQueue = { item, queueId, onDuplicate, categoryId ->
addDownload(
item = item,
queueId = queueId,
categoryId = categoryId,
onDuplicateStrategy = onDuplicate,
)
closeAddDownloadDialog(dialogId = config.id)
},
onRequestDownload = { item, onDuplicate ->
startNewDownload(item, onDuplicate, true)
onRequestAddCategory = {
openCategoryDialog(-1)
},
onRequestDownload = { item, onDuplicate, categoryId ->
startNewDownload(
item = item,
onDuplicateStrategy = onDuplicate,
openDownloadDialog = true,
categoryId = categoryId,
)
closeAddDownloadDialog(config.id)
},
openExistingDownload = {
@ -213,16 +232,20 @@ class AppComponent(
is AddDownloadConfig.MultipleAddConfig -> {
AddMultiDownloadComponent(
ctx,
config.id,
{ closeAddDownloadDialog(config.id) },
{ items, strategy, queueId ->
ctx = ctx,
id = config.id,
onRequestClose = { closeAddDownloadDialog(config.id) },
onRequestAdd = { items, strategy, queueId, categorySelectionMode ->
addDownload(
items = items,
onDuplicateStrategy = strategy,
queueId = queueId,
categorySelectionMode = categorySelectionMode
)
closeAddDownloadDialog(config.id)
},
onRequestAddCategory = {
openCategoryDialog(-1)
}
).apply { addItems(config.links) }
}
@ -263,6 +286,77 @@ class AppComponent(
.map { it.items.mapNotNull { it.instance } }
.stateIn(scope, SharingStarted.Eagerly, emptyList())
private val categoryManager: CategoryManager by inject()
private val categoryPageControl = PagesNavigation<Long>()
private val _openedCategoryDialogs = childPages(
key = "openedCategoryDialogs",
source = categoryPageControl,
serializer = null,
initialPages = { Pages() },
pageStatus = { _, _ ->
ChildNavState.Status.RESUMED
},
childFactory = { cfg, ctx ->
CategoryComponent(
ctx = ctx,
close = {
closeCategoryDialog(cfg)
},
submit = { submittedCategory ->
if (submittedCategory.id < 0) {
categoryManager.addCustomCategory(submittedCategory)
} else {
categoryManager.updateCategory(
submittedCategory.id
) {
submittedCategory.copy(
items = it.items
)
}
}
closeCategoryDialog(cfg)
},
id = cfg
)
}
).subscribeAsStateFlow()
override val openedCategoryDialogs: StateFlow<List<CategoryComponent>> = _openedCategoryDialogs
.map {
it.items.mapNotNull { it.instance }
}.stateIn(scope, SharingStarted.Eagerly, emptyList())
override fun openCategoryDialog(categoryId: Long) {
scope.launch {
val component = openedCategoryDialogs.value.find {
it.id == categoryId
}
if (component != null) {
// component.bringToFront()
} else {
categoryPageControl.navigate {
val newItems = (it.items.toSet() + categoryId).toList()
val copy = it.copy(
items = newItems,
selectedIndex = newItems.lastIndex
)
copy
}
}
}
}
override fun closeCategoryDialog(categoryId: Long) {
scope.launch {
categoryPageControl.navigate {
val newItems = it.items.filter { config ->
config != categoryId
}
it.copy(items = newItems, selectedIndex = newItems.lastIndex)
}
}
}
init {
downloadSystem.downloadEvents
.filterIsInstance<DownloadManagerEvents.OnJobRemoved>()
@ -518,6 +612,7 @@ class AppComponent(
fun addDownload(
items: List<DownloadItem>,
onDuplicateStrategy: (DownloadItem) -> OnDuplicateStrategy,
categorySelectionMode: CategorySelectionMode?,
queueId: Long?,
) {
scope.launch {
@ -525,6 +620,7 @@ class AppComponent(
newItemsToAdd = items,
onDuplicateStrategy = onDuplicateStrategy,
queueId = queueId,
categorySelectionMode = categorySelectionMode,
)
}
}
@ -532,6 +628,7 @@ class AppComponent(
fun addDownload(
item: DownloadItem,
queueId: Long?,
categoryId: Long?,
onDuplicateStrategy: OnDuplicateStrategy,
) {
scope.launch {
@ -539,6 +636,7 @@ class AppComponent(
downloadItem = item,
onDuplicateStrategy = onDuplicateStrategy,
queueId = queueId,
categoryId = categoryId,
)
}
}
@ -547,12 +645,14 @@ class AppComponent(
item: DownloadItem,
onDuplicateStrategy: OnDuplicateStrategy,
openDownloadDialog: Boolean,
categoryId: Long?,
) {
scope.launch {
val id = downloadSystem.addDownload(
item,
onDuplicateStrategy,
DefaultQueueInfo.ID,
downloadItem = item,
onDuplicateStrategy = onDuplicateStrategy,
queueId = DefaultQueueInfo.ID,
categoryId = categoryId,
)
launch {
downloadSystem.manualResume(id)

View File

@ -12,6 +12,7 @@ import ir.amirab.util.compose.action.buildMenu
import ir.amirab.util.compose.action.simpleAction
import com.abdownloadmanager.desktop.utils.getIcon
import com.abdownloadmanager.desktop.utils.getName
import com.abdownloadmanager.utils.category.Category
import ir.amirab.downloader.downloaditem.DownloadCredentials
import ir.amirab.downloader.queue.DownloadQueue
import ir.amirab.downloader.queue.activeQueuesFlow
@ -207,6 +208,21 @@ fun moveToQueueAction(
}
}
}
fun createMoveToCategoryAction(
category: Category,
itemIds: List<Long>,
): AnAction {
return simpleAction(category.name) {
scope.launch {
downloadSystem
.categoryManager
.addItemsToCategory(
categoryId = category.id,
itemIds = itemIds,
)
}
}
}
fun stopQueueAction(
queue: DownloadQueue,

View File

@ -8,6 +8,7 @@ import com.abdownloadmanager.desktop.pages.settings.ThemeManager
import ir.amirab.downloader.queue.QueueManager
import com.abdownloadmanager.desktop.repository.AppRepository
import com.abdownloadmanager.desktop.storage.*
import com.abdownloadmanager.desktop.ui.icon.MyIcons
import com.abdownloadmanager.desktop.utils.*
import com.abdownloadmanager.desktop.utils.native_messaging.NativeMessaging
import com.abdownloadmanager.desktop.utils.native_messaging.NativeMessagingManifestApplier
@ -34,6 +35,10 @@ import org.koin.dsl.bind
import org.koin.dsl.module
import com.abdownloadmanager.updatechecker.DummyUpdateChecker
import com.abdownloadmanager.updatechecker.UpdateChecker
import com.abdownloadmanager.utils.FileIconProvider
import com.abdownloadmanager.utils.FileIconProviderUsingCategoryIcons
import com.abdownloadmanager.utils.category.*
import com.abdownloadmanager.utils.compose.IMyIcons
import ir.amirab.downloader.monitor.IDownloadMonitor
import ir.amirab.downloader.utils.EmptyFileCreator
@ -92,7 +97,7 @@ val downloaderModule = module {
}
single {
val downloadSettings:DownloadSettings = get()
val downloadSettings: DownloadSettings = get()
EmptyFileCreator(
diskStat = get(),
useSparseFile = { downloadSettings.useSparseFileAllocation }
@ -104,8 +109,42 @@ val downloaderModule = module {
single<IDownloadMonitor> {
DownloadMonitor(get())
}
}
val downloadSystemModule = module {
single {
DownloadSystem(get(), get(), get(), get(), get(), get())
CategoryFileStorage(
file = get<DownloadFoldersRegistry>().registerAndGet(
AppInfo.downloadDbDir.resolve("categories")
).resolve("categories.json"),
fileSaver = get()
)
}.bind<CategoryStorage>()
single {
FileIconProviderUsingCategoryIcons(
get(),
get(),
MyIcons,
)
}.bind<FileIconProvider>()
single {
DefaultCategories(
icons = get(),
getDefaultDownloadFolder = {
get<AppSettingsStorage>().defaultDownloadFolder.value
}
)
}
single {
CategoryManager(
categoryStorage = get(),
scope = get(),
defaultCategoriesFactory = get(),
downloadManager = get(),
)
}
single {
DownloadSystem(get(), get(), get(), get(), get(), get(), get())
}
}
val coroutineModule = module {
@ -154,6 +193,7 @@ val nativeMessagingModule = module {
val appModule = module {
includes(downloaderModule)
includes(downloadSystemModule)
includes(coroutineModule)
includes(jsonModule)
includes(integrationModule)
@ -167,8 +207,11 @@ val appModule = module {
AppRepository()
}
single {
ThemeManager(get(),get())
ThemeManager(get(), get())
}
single {
MyIcons
}.bind<IMyIcons>()
single {
AppSettingsStorage(
createMyConfigPreferences(

View File

@ -31,7 +31,7 @@ fun ShowAddDownloadDialogs(component: AddDownloadDialogManager) {
}
when (addDownloadComponent) {
is AddSingleDownloadComponent -> {
val h = 220
val h = 265
val w = 500
val size = remember {
DpSize(
@ -61,7 +61,7 @@ fun ShowAddDownloadDialogs(component: AddDownloadDialogManager) {
is AddMultiDownloadComponent -> {
val h = 400
val w = 600
val w = 700
val state = rememberWindowState(
height = h.dp,
width = w.dp,

View File

@ -9,6 +9,11 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.abdownloadmanager.desktop.pages.addDownload.multiple.AddMultiItemSaveMode.*
import com.abdownloadmanager.desktop.utils.asState
import com.abdownloadmanager.utils.category.Category
import com.abdownloadmanager.utils.category.CategoryManager
import com.abdownloadmanager.utils.category.CategorySelectionMode
import com.arkivanov.decompose.ComponentContext
import ir.amirab.downloader.connection.DownloaderClient
import ir.amirab.downloader.downloaditem.DownloadCredentials
@ -25,10 +30,11 @@ class AddMultiDownloadComponent(
id: String,
private val onRequestClose: () -> Unit,
private val onRequestAdd: OnRequestAdd,
private val onRequestAddCategory: () -> Unit,
) : AddDownloadComponent(ctx, id),
KoinComponent {
val tableState= TableState(
val tableState = TableState(
cells = AddMultiItemTableCells.all(),
forceVisibleCells = listOf(
AddMultiItemTableCells.Check,
@ -38,15 +44,38 @@ class AddMultiDownloadComponent(
private val appSettings by inject<AppRepository>()
private val client by inject<DownloaderClient>()
val downloadSystem by inject<DownloadSystem>()
private val _folder=MutableStateFlow(appSettings.saveLocation.value)
private val _folder = MutableStateFlow(appSettings.saveLocation.value)
val folder = _folder.asStateFlow()
fun setFolder(folder:String) {
fun setFolder(folder: String) {
this._folder.update { folder }
list.forEach {
it.folder.update { folder }
}
}
// when we select all files in one location let user option to auto categorize items
private val _alsoAutoCategorize = MutableStateFlow(true)
val alsoAutoCategorize = _alsoAutoCategorize.asStateFlow()
fun setAlsoAutoCategorize(value: Boolean) {
_alsoAutoCategorize.update { value }
}
private val categoryManager: CategoryManager by inject()
val categories = categoryManager.categoriesFlow
private val _selectedCategory = MutableStateFlow(categories.value.firstOrNull())
val selectedCategory = _selectedCategory.asStateFlow()
fun setSelectedCategory(category: Category) {
_selectedCategory.update {
category
}
}
fun requestAddCategory() {
onRequestAddCategory()
}
private fun newChecker(iDownloadCredentials: DownloadCredentials) = DownloadUiChecker(
initialCredentials = iDownloadCredentials,
initialName = "",
@ -69,6 +98,12 @@ class AddMultiDownloadComponent(
}
var list: List<DownloadUiChecker> by mutableStateOf(emptyList())
private val _saveMode = MutableStateFlow(EachFileInTheirOwnCategory)
val saveMode = _saveMode.asStateFlow()
fun setSaveMode(saveMode: AddMultiItemSaveMode) {
_saveMode.update { saveMode }
}
private val checkList = MutableSharedFlow<DownloadUiChecker>()
private fun enqueueCheck(links: List<DownloadUiChecker>) {
@ -121,15 +156,59 @@ class AddMultiDownloadComponent(
}
}
val isCategoryModeHasValidState by run {
val category by selectedCategory.asState(scope)
val saveMode by saveMode.asState(scope)
derivedStateOf {
when (saveMode) {
EachFileInTheirOwnCategory -> true
AllInOneCategory -> category != null
InSameLocation -> true
}
}
}
val canClickAdd by derivedStateOf {
selectionList.isNotEmpty()
selectionList.isNotEmpty() && isCategoryModeHasValidState
}
private val queueManager: QueueManager by inject()
val queueList = queueManager.queues
private fun getFolderForItem(
categorySelectionMode: CategorySelectionMode?,
fleName: String,
defaultFolder: String,
): String {
return when (categorySelectionMode) {
CategorySelectionMode.Auto -> {
downloadSystem.categoryManager
.getCategoryOfFileName(fleName)?.path
?: defaultFolder
}
is CategorySelectionMode.Fixed -> {
downloadSystem.categoryManager
.getCategoryById(categorySelectionMode.categoryId)?.path
?: defaultFolder
}
null -> defaultFolder
}
}
fun requestAddDownloads(
queueId: Long?
queueId: Long?,
) {
val categorySelectionMode = when (saveMode.value) {
EachFileInTheirOwnCategory -> CategorySelectionMode.Auto
AllInOneCategory -> selectedCategory.value?.let {
CategorySelectionMode.Fixed(it.id)
}
InSameLocation -> {
if (alsoAutoCategorize.value) CategorySelectionMode.Auto
else null
}
}
val itemsToAdd = list
.filter { it.credentials.value.link in selectionList }
.filter {
@ -139,7 +218,11 @@ class AddMultiDownloadComponent(
.map {
DownloadItem(
id = -1,
folder = it.folder.value,
folder = getFolderForItem(
categorySelectionMode = categorySelectionMode,
fleName = it.name.value,
defaultFolder = it.folder.value
),
name = it.name.value,
link = it.credentials.value.link,
contentLength = it.length.value ?: -1,
@ -149,9 +232,13 @@ class AddMultiDownloadComponent(
onRequestAdd(
items = itemsToAdd,
onDuplicateStrategy = { OnDuplicateStrategy.AddNumbered },
queueId = queueId
queueId = queueId,
categorySelectionMode = categorySelectionMode
)
addToLastUsedLocations(folder.value)
val folder = folder.value
if (saveMode.value == InSameLocation) {
addToLastUsedLocations(folder)
}
requestClose()
}
}
@ -176,6 +263,7 @@ fun interface OnRequestAdd {
operator fun invoke(
items: List<DownloadItem>,
onDuplicateStrategy: (DownloadItem) -> OnDuplicateStrategy,
queueId: Long?
queueId: Long?,
categorySelectionMode: CategorySelectionMode?,
)
}

View File

@ -1,40 +1,72 @@
package com.abdownloadmanager.desktop.pages.addDownload.multiple
import com.abdownloadmanager.desktop.pages.addDownload.shared.ShowAddToQueueDialog
import com.abdownloadmanager.desktop.pages.addDownload.shared.LocationTextField
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import com.abdownloadmanager.desktop.ui.theme.myTextSizes
import com.abdownloadmanager.desktop.ui.widget.ActionButton
import com.abdownloadmanager.desktop.ui.widget.Text
import com.abdownloadmanager.utils.compose.WithContentAlpha
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.foundation.onClick
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.window.WindowDraggableArea
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
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.addDownload.shared.*
import com.abdownloadmanager.desktop.ui.customwindow.BaseOptionDialog
import com.abdownloadmanager.desktop.ui.icon.MyIcons
import com.abdownloadmanager.desktop.ui.theme.myColors
import com.abdownloadmanager.desktop.ui.util.ifThen
import com.abdownloadmanager.desktop.ui.widget.CheckBox
import com.abdownloadmanager.desktop.ui.widget.Help
import com.abdownloadmanager.desktop.utils.div
import com.abdownloadmanager.desktop.utils.windowUtil.moveSafe
import com.abdownloadmanager.utils.category.CategorySelectionMode
import com.abdownloadmanager.utils.compose.WithContentColor
import com.abdownloadmanager.utils.compose.widget.MyIcon
import java.awt.MouseInfo
@Composable
fun AddMultiItemPage(
addMultiDownloadComponent: AddMultiDownloadComponent,
) {
Column(Modifier
.padding(horizontal = 16.dp)
.padding(top = 8.dp,bottom = 16.dp)
) {
WithContentAlpha(1f) {
Text(
"Select Items you want to pick up for download",
fontSize = myTextSizes.base
Column(Modifier) {
Column(
Modifier
.padding(horizontal = 16.dp)
.padding(top = 8.dp)
.weight(1f)
) {
WithContentAlpha(1f) {
Text(
"Select Items you want to pick up for download",
fontSize = myTextSizes.base
)
}
Spacer(Modifier.height(8.dp))
AddMultiDownloadTable(
Modifier.weight(1f),
addMultiDownloadComponent,
)
}
Spacer(Modifier.height(8.dp))
AddMultiDownloadTable(
Modifier.weight(1f),
Footer(
Modifier,
addMultiDownloadComponent,
)
Footer(addMultiDownloadComponent)
}
if (addMultiDownloadComponent.showAddToQueue){
if (addMultiDownloadComponent.showAddToQueue) {
ShowAddToQueueDialog(
queueList = addMultiDownloadComponent.queueList.collectAsState().value,
onQueueSelected = {
@ -50,35 +82,376 @@ fun AddMultiItemPage(
}
@Composable
fun Footer(component: AddMultiDownloadComponent) {
Row(
Modifier,
verticalAlignment = Alignment.CenterVertically,
fun Footer(
modifier: Modifier = Modifier,
component: AddMultiDownloadComponent,
) {
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 = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
SaveSettings(
modifier = Modifier.width(300.dp),
component = component,
)
Spacer(Modifier.weight(1f))
Spacer(Modifier.width(8.dp))
Row(Modifier.align(Alignment.Bottom)) {
ActionButton(
text = "Add",
onClick = {
component.openAddToQueueDialog()
},
enabled = component.canClickAdd,
modifier = Modifier,
)
Spacer(Modifier.width(8.dp))
ActionButton(
text = "Cancel",
onClick = {
component.requestClose()
},
modifier = Modifier,
)
}
}
}
}
@Composable
private fun SaveSettings(
modifier: Modifier,
component: AddMultiDownloadComponent,
) {
Column(
modifier.animateContentSize()
) {
ActionButton(
text = "Add",
onClick = {
component.openAddToQueueDialog()
var dropdownOpen by remember { mutableStateOf(false) }
val saveMode by component.saveMode.collectAsState()
Text("Save to:")
Spacer(Modifier.height(8.dp))
SaveSolution(
saveMode = saveMode,
setSaveMode = {
component.setSaveMode(it)
},
enabled = component.canClickAdd,
modifier = Modifier,
isSelectionOpen = dropdownOpen,
setSelectionOpen = {
dropdownOpen = it
}
)
Spacer(Modifier.width(8.dp))
LocationTextField(
text = component.folder.collectAsState().value,
setText = {
component.setFolder(it)
},
modifier = Modifier.weight(1f),
lastUsedLocations = component.lastUsedLocations.collectAsState().value
)
Spacer(Modifier.width(8.dp))
ActionButton(
text = "Cancel",
onClick = {
component.requestClose()
},
modifier = Modifier,
when (saveMode) {
AddMultiItemSaveMode.EachFileInTheirOwnCategory -> {
//
}
AddMultiItemSaveMode.AllInOneCategory -> {
Spacer(Modifier.height(8.dp))
Row(
Modifier.height(IntrinsicSize.Max),
verticalAlignment = Alignment.CenterVertically,
) {
CategorySelect(
categories = component.categories.collectAsState().value,
modifier = Modifier.weight(1f),
selectedCategory = component.selectedCategory.collectAsState().value,
onCategorySelected = {
component.setSelectedCategory(it)
}
)
Spacer(Modifier.width(8.dp))
CategoryAddButton(
Modifier.fillMaxHeight(),
enabled = true,
onClick = {
component.requestAddCategory()
},
)
}
}
AddMultiItemSaveMode.InSameLocation -> {
Spacer(Modifier.height(8.dp))
AllFilesInSameDirectory(
Modifier,
folder = component.folder.collectAsState().value,
setFolder = {
component.setFolder(it)
},
alsoCategorize = component.alsoAutoCategorize.collectAsState().value,
setAlsoCategorize = component::setAlsoAutoCategorize,
knownLocations = component.lastUsedLocations.collectAsState().value,
)
}
}
}
}
@Composable
private fun SaveSolution(
saveMode: AddMultiItemSaveMode,
setSaveMode: (AddMultiItemSaveMode) -> Unit,
isSelectionOpen: Boolean,
setSelectionOpen: (Boolean) -> Unit,
) {
SaveSolutionHeader(
saveMode = saveMode,
onClick = {
setSelectionOpen(!isSelectionOpen)
},
)
if (isSelectionOpen) {
SaveSolutionPopup(
selectedItem = saveMode,
onIteSelected = setSaveMode,
onDismiss = {
setSelectionOpen(false)
}
)
}
}
@Composable
private fun SaveSolutionPopup(
selectedItem: AddMultiItemSaveMode,
onIteSelected: (AddMultiItemSaveMode) -> Unit,
onDismiss: () -> Unit,
) {
val state = rememberDialogState(
size = DpSize(
height = Dp.Unspecified,
width = Dp.Unspecified,
),
)
val close = {
onDismiss()
}
BaseOptionDialog(
onCloseRequest = close,
state = state,
resizeable = false,
) {
LaunchedEffect(window) {
window.moveSafe(
MouseInfo.getPointerInfo().location.run {
DpOffset(
x = x.dp,
y = y.dp
)
}
)
}
val shape = RoundedCornerShape(6.dp)
Column(
Modifier
.clip(shape)
.border(2.dp, myColors.onBackground / 10, shape)
.background(
Brush.linearGradient(
listOf(
myColors.surface,
myColors.background,
)
)
)
) {
WithContentColor(myColors.onBackground) {
Column(
Modifier.widthIn(max = 300.dp)
) {
WindowDraggableArea(Modifier) {
Column(
Modifier.padding(16.dp)
) {
Text(
"Where should each item saved?",
Modifier,
fontSize = myTextSizes.base
)
Spacer(Modifier.height(8.dp))
WithContentAlpha(0.75f) {
Text(
"There are multiple items! please select a way you want to save them",
Modifier,
fontSize = myTextSizes.sm,
)
}
}
}
Column(
Modifier
.padding(horizontal = 8.dp)
.padding(bottom = 8.dp)
) {
Spacer(Modifier.height(4.dp))
Spacer(
Modifier.fillMaxWidth()
.height(1.dp)
.background(myColors.onBackground / 10),
)
Spacer(Modifier.height(4.dp))
Column {
for (item in AddMultiItemSaveMode.entries) {
SaveSolutionItem(
title = item.title,
description = item.description,
isSelected = selectedItem == item,
onClick = {
onIteSelected(item)
close()
}
)
}
}
}
}
}
}
}
}
@Composable
private fun SaveSolutionHeader(
saveMode: AddMultiItemSaveMode,
onClick: () -> Unit,
enabled: Boolean = true,
) {
val borderColor = myColors.onBackground / 0.1f
val background = myColors.surface / 50
val shape = RoundedCornerShape(6.dp)
Row(
Modifier
.height(IntrinsicSize.Max)
.clip(shape)
.ifThen(!enabled) {
alpha(0.5f)
}
.border(1.dp, borderColor, shape)
.background(background)
.clickable(
enabled = enabled
) { onClick() }
.padding(horizontal = 8.dp)
) {
val contentModifier = Modifier
.padding(vertical = 8.dp)
.weight(1f)
Text(
saveMode.title,
contentModifier,
)
Spacer(
Modifier
.padding(horizontal = 8.dp)
.fillMaxHeight().padding(vertical = 1.dp)
.width(1.dp)
.background(borderColor)
)
MyIcon(
MyIcons.down,
null,
Modifier
.align(Alignment.CenterVertically)
.size(16.dp),
)
}
}
@Composable
private fun SaveSolutionItem(
title: String,
description: String,
isSelected: Boolean?,
onClick: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(8.dp)
) {
isSelected?.let {
CheckBox(isSelected, { onClick() }, size = 12.dp)
}
Spacer(Modifier.width(8.dp))
Column {
Text(
title,
fontSize = myTextSizes.base,
fontWeight = FontWeight.Bold
)
Spacer(Modifier.height(4.dp))
WithContentAlpha(0.7f) {
Text(
text = description,
fontSize = myTextSizes.sm,
modifier = Modifier
)
}
}
}
}
@Composable
private fun AllFilesInSameDirectory(
modifier: Modifier,
folder: String,
setFolder: (String) -> Unit,
alsoCategorize: Boolean,
setAlsoCategorize: (Boolean) -> Unit,
knownLocations: List<String>,
) {
LocationTextField(
text = folder,
setText = {
setFolder(it)
},
modifier = modifier,
lastUsedLocations = knownLocations
)
Spacer(Modifier.height(8.dp))
Row(
Modifier.onClick {
setAlsoCategorize(!alsoCategorize)
},
verticalAlignment = Alignment.CenterVertically,
) {
CheckBox(
value = alsoCategorize,
onValueChange = setAlsoCategorize
)
Spacer(Modifier.width(4.dp))
Text("Auto categorize")
}
}
enum class AddMultiItemSaveMode(
val title: String,
val description: String,
) {
EachFileInTheirOwnCategory(
title = "Each item on its own category",
description = "Each item will be placed in a category that have that file type",
),
AllInOneCategory(
title = "All items in one Category",
description = "All files will be saved in the selected category location",
),
InSameLocation(
title = "All items in one Location",
description = "All items will be saved in the selected directory",
);
}

View File

@ -0,0 +1,121 @@
package com.abdownloadmanager.desktop.pages.addDownload.shared
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.*
import com.abdownloadmanager.desktop.ui.icon.MyIcons
import com.abdownloadmanager.desktop.ui.theme.myColors
import com.abdownloadmanager.desktop.ui.util.ifThen
import com.abdownloadmanager.desktop.ui.widget.Text
import com.abdownloadmanager.desktop.utils.div
import com.abdownloadmanager.utils.category.Category
import com.abdownloadmanager.utils.category.rememberIconPainter
import com.abdownloadmanager.utils.compose.widget.MyIcon
@Composable
fun CategorySelect(
modifier: Modifier = Modifier,
enabled: Boolean = true,
categories: List<Category>,
selectedCategory: Category?,
onCategorySelected: (Category) -> Unit,
) {
var isSelectionOpen by remember {
mutableStateOf(false)
}
val closeDialog = {
isSelectionOpen = false
}
DialogDropDown(
selectedItem = selectedCategory,
possibleItems = categories,
onItemSelected = onCategorySelected,
enabled = enabled,
renderItem = {
RenderCategory(
category = it,
modifier = Modifier,
)
},
dropdownOpen = isSelectionOpen,
onRequestCloseDropDown = {
closeDialog()
},
onRequestOpenDropDown = {
isSelectionOpen = true
},
modifier = modifier,
dropDownSize = DpSize(220.dp, 220.dp),
)
}
@Composable
private fun RenderCategory(
modifier: Modifier,
category: Category,
) {
Row(
modifier,
verticalAlignment = Alignment.CenterVertically,
) {
val icon = category.rememberIconPainter()
val iconModifier = Modifier.size(16.dp)
if (icon != null) {
MyIcon(
icon,
null,
iconModifier,
)
} else {
Spacer(iconModifier)
}
Spacer(Modifier.width(8.dp))
Text(
category.name,
softWrap = false,
maxLines = 1,
modifier = Modifier.weight(1f)
)
}
}
@Composable
fun CategoryAddButton(
modifier: Modifier,
enabled: Boolean = true,
onClick: () -> Unit,
) {
val borderColor = myColors.onBackground / 0.1f
val background = myColors.surface / 50
val shape = RoundedCornerShape(6.dp)
Box(
modifier
.clip(shape)
.ifThen(!enabled) {
alpha(0.5f)
}
.border(1.dp, borderColor, shape)
.background(background)
.clickable(
enabled = enabled
) { onClick() }
.aspectRatio(1f)
// .padding(horizontal = 8.dp)
) {
MyIcon(
MyIcons.add,
contentDescription = "Add Category",
Modifier
.align(Alignment.Center)
.size(16.dp)
)
}
}

View File

@ -0,0 +1,230 @@
package com.abdownloadmanager.desktop.pages.addDownload.shared
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
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.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
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.ui.customwindow.BaseOptionDialog
import com.abdownloadmanager.desktop.ui.icon.MyIcons
import com.abdownloadmanager.desktop.ui.theme.myColors
import com.abdownloadmanager.desktop.ui.util.ifThen
import com.abdownloadmanager.desktop.ui.widget.Text
import com.abdownloadmanager.desktop.utils.div
import com.abdownloadmanager.desktop.utils.windowUtil.moveSafe
import com.abdownloadmanager.utils.compose.WithContentAlpha
import com.abdownloadmanager.utils.compose.widget.MyIcon
import java.awt.MouseInfo
@Composable
fun <T> DialogDropDown(
selectedItem: T?,
possibleItems: List<T>,
onItemSelected: (T) -> Unit,
modifier: Modifier,
enabled: Boolean = true,
dropdownOpen: Boolean,
onRequestOpenDropDown: () -> Unit,
onRequestCloseDropDown: () -> Unit,
dropDownSize: DpSize = DpSize(220.dp, 250.dp),
renderItem: @Composable (T) -> Unit,
) {
Column(modifier) {
DropDownHeader(
item = selectedItem,
enabled = enabled,
onClick = onRequestOpenDropDown,
renderItem = renderItem
)
if (dropdownOpen) {
DropDownContent(
closeDialog = onRequestCloseDropDown,
dropDownSize = dropDownSize,
possibleItems = possibleItems,
selectedItem = selectedItem,
onItemSelected = onItemSelected,
renderItem = renderItem,
)
}
}
}
@Composable
fun <T> DropDownContent(
closeDialog: () -> Unit,
dropDownSize: DpSize,
possibleItems: List<T>,
selectedItem: T?,
onItemSelected: (T) -> Unit,
renderItem: @Composable (T) -> Unit,
) {
BaseOptionDialog(
onCloseRequest = closeDialog,
state = rememberDialogState(
size = dropDownSize
),
resizeable = true,
content = {
LaunchedEffect(window) {
window.moveSafe(
MouseInfo.getPointerInfo().location.run {
DpOffset(
x = x.dp,
y = y.dp
)
}
)
}
val shape = RoundedCornerShape(6.dp)
Box(
Modifier
.clip(shape)
.border(2.dp, myColors.onBackground / 10, shape)
.background(
Brush.linearGradient(
listOf(
myColors.surface,
myColors.background,
)
)
)
) {
val listState = rememberLazyListState()
LazyColumn(
Modifier
.fillMaxSize()
.padding(8.dp),
state = listState,
) {
items(possibleItems) {
val isSelected = it == selectedItem
WithContentAlpha(
if (isSelected) 1f else 0.75f
) {
Row(
Modifier
.clip(shape)
.clickable {
onItemSelected(it)
closeDialog()
}
.padding(
vertical = 8.dp,
horizontal = 8.dp
)
) {
Box(
Modifier.weight(1f)
) {
renderItem(it)
}
val selectedIconModifier = Modifier.size(16.dp)
if (isSelected) {
MyIcon(
MyIcons.check,
null,
selectedIconModifier,
)
} else {
Spacer(selectedIconModifier)
}
}
}
}
}
AnimatedVisibility(
visible = listState.canScrollForward,
modifier = Modifier.matchParentSize(),
enter = fadeIn(),
exit = fadeOut(),
) {
Spacer(
Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colorStops = arrayOf(
0f to Color.Transparent,
0.8f to Color.Transparent,
1f to myColors.background,
)
)
)
)
}
}
}
)
}
@Composable
private fun <T> DropDownHeader(
item: T?,
enabled: Boolean,
onClick: () -> Unit,
renderItem: @Composable (T) -> Unit,
) {
val borderColor = myColors.onBackground / 0.1f
val background = myColors.surface / 50
val shape = RoundedCornerShape(6.dp)
Row(
Modifier
.height(IntrinsicSize.Max)
.clip(shape)
.ifThen(!enabled) {
alpha(0.5f)
}
.border(1.dp, borderColor, shape)
.background(background)
.clickable(
enabled = enabled
) { onClick() }
.padding(horizontal = 8.dp)
) {
val contentModifier = Modifier
.padding(vertical = 8.dp)
.weight(1f)
if (item != null) {
Box(contentModifier) {
renderItem(item)
}
} else {
Text(
"No Category Selected",
contentModifier
)
}
Spacer(
Modifier
.padding(horizontal = 8.dp)
.fillMaxHeight().padding(vertical = 1.dp)
.width(1.dp)
.background(borderColor)
)
MyIcon(
MyIcons.down,
null,
Modifier
.align(Alignment.CenterVertically)
.size(16.dp),
)
}
}

View File

@ -1,9 +1,5 @@
package com.abdownloadmanager.desktop.pages.addDownload.single
import com.abdownloadmanager.desktop.pages.addDownload.shared.ExtraConfig
import com.abdownloadmanager.desktop.pages.addDownload.shared.LocationTextField
import com.abdownloadmanager.desktop.pages.addDownload.shared.ShowAddToQueueDialog
import com.abdownloadmanager.desktop.pages.home.sections.category.DefinedTypeCategories
import com.abdownloadmanager.utils.compose.WithContentAlpha
import com.abdownloadmanager.utils.compose.WithContentColor
import com.abdownloadmanager.desktop.ui.customwindow.BaseOptionDialog
@ -34,8 +30,9 @@ 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.*
import com.abdownloadmanager.desktop.utils.mvi.HandleEffects
import ir.amirab.downloader.monitor.ProcessingDownloadItemState
import com.abdownloadmanager.utils.category.rememberIconPainter
import ir.amirab.downloader.utils.OnDuplicateStrategy
import java.awt.MouseInfo
@ -79,6 +76,47 @@ fun AddDownloadPage(
) {
val canAddResult by component.canAddResult.collectAsState()
Column(Modifier.weight(1f)) {
val useCategory by component.useCategory.collectAsState()
Spacer(Modifier.size(8.dp))
Row(
modifier = Modifier.height(IntrinsicSize.Max),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.onClick {
component.setUseCategory(!useCategory)
}
.padding(vertical = 4.dp)
) {
CheckBox(
size = 16.dp,
value = useCategory,
onValueChange = { component.setUseCategory(it) }
)
Spacer(Modifier.width(4.dp))
Text("Use Category")
}
Spacer(Modifier.width(8.dp))
CategorySelect(
modifier = Modifier.weight(1f),
enabled = useCategory,
categories = component.categories.collectAsState().value,
selectedCategory = component.selectedCategory.collectAsState().value,
onCategorySelected = {
component.setSelectedCategory(it)
},
)
Spacer(Modifier.width(8.dp))
CategoryAddButton(
enabled = useCategory,
modifier = Modifier.fillMaxHeight(),
onClick = {
component.addNewCategory()
},
)
}
Spacer(Modifier.size(8.dp))
LocationTextField(
modifier = Modifier.fillMaxWidth(),
@ -273,7 +311,8 @@ private fun OnDuplicateStrategySolutionItem(
}
Spacer(Modifier.width(8.dp))
Column {
Text(title,
Text(
title,
fontSize = myTextSizes.base,
fontWeight = FontWeight.Bold
)
@ -419,7 +458,7 @@ private fun MainActionButtons(component: AddSingleDownloadComponent) {
modifier = Modifier,
onClick = { component.showSolutionsOnDuplicateDownloadUi = true },
)
if(component.shouldShowOpenFile.collectAsState().value){
if (component.shouldShowOpenFile.collectAsState().value) {
Spacer(Modifier.width(8.dp))
MainConfigActionButton(
text = "Open File",
@ -475,6 +514,7 @@ fun RenderFileTypeAndSize(
) {
val isLinkLoading by component.isLinkLoading.collectAsState()
val fileInfo by component.linkResponseInfo.collectAsState()
val fileIconProvider = component.iconProvider
val iconModifier = Modifier.size(16.dp)
Box(Modifier.padding(top = 16.dp)) {
AnimatedContent(
@ -488,11 +528,7 @@ fun RenderFileTypeAndSize(
} else {
// val extension = getExtension(fileInfo?.fileName ?: usersSetFileName) ?: "unknown"
val downloadItem by component.downloadItem.collectAsState()
val category = remember(downloadItem) {
DefinedTypeCategories.resolveCategoryForDownloadItem(
ProcessingDownloadItemState.onlyDownloadItem(downloadItem)
)
}
val icon = fileIconProvider.rememberIcon(downloadItem.name)
// val bitmap = FileIconProvider.getIconOfFileExtension(extension)
@ -513,7 +549,7 @@ fun RenderFileTypeAndSize(
)
}
MyIcon(
category.icon,
icon,
null,
iconModifier
)
@ -577,8 +613,9 @@ private fun UrlTextField(
modifier = modifier.fillMaxWidth(),
end = {
MyTextFieldIcon(MyIcons.paste) {
setText(ClipboardUtil.read()
.orEmpty()
setText(
ClipboardUtil.read()
.orEmpty()
)
}
},
@ -590,7 +627,7 @@ private fun UrlTextField(
private fun NameTextField(
text: String,
setText: (String) -> Unit,
errorText: String? = null
errorText: String? = null,
) {
AddDownloadPageTextField(
text,

View File

@ -27,6 +27,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import com.abdownloadmanager.utils.FileIconProvider
import com.abdownloadmanager.utils.category.Category
import com.abdownloadmanager.utils.category.CategoryManager
sealed interface AddSingleDownloadPageEffects {
data class SuggestUrl(val link: String) : AddSingleDownloadPageEffects
@ -35,10 +38,11 @@ sealed interface AddSingleDownloadPageEffects {
class AddSingleDownloadComponent(
ctx: ComponentContext,
val onRequestClose: () -> Unit,
val onRequestDownload: (DownloadItem, OnDuplicateStrategy) -> Unit,
val onRequestAddToQueue: (DownloadItem, queueId: Long?, OnDuplicateStrategy) -> Unit,
val onRequestDownload: OnRequestDownloadSingleItem,
val onRequestAddToQueue: OnRequestAddSingleItem,
val onRequestAddCategory: () -> Unit,
val openExistingDownload: (Long) -> Unit,
private val downloadItemOpener:DownloadItemOpener,
private val downloadItemOpener: DownloadItemOpener,
id: String,
) : AddDownloadComponent(ctx, id),
KoinComponent,
@ -47,6 +51,44 @@ class AddSingleDownloadComponent(
private val appSettings: AppRepository by inject()
private val client: DownloaderClient by inject()
val downloadSystem: DownloadSystem by inject()
val iconProvider: FileIconProvider by inject()
private val categoryManager: CategoryManager by inject()
val categories = categoryManager.categoriesFlow
private val _selectedCategory: MutableStateFlow<Category?> = MutableStateFlow(categories.value.firstOrNull())
val selectedCategory = _selectedCategory.asStateFlow()
private val _useCategory = MutableStateFlow(false)
val useCategory = _useCategory.asStateFlow()
fun setUseCategory(value: Boolean) {
_useCategory.update { value }
if (value) {
useCategoryFolder()
} else {
useDefaultFolder()
}
}
private fun useCategoryFolder() {
val category = selectedCategory.value
if (useCategory.value && category != null) {
setFolder(category.path)
}
}
private fun useDefaultFolder() {
setFolder(appSettings.saveLocation.value)
}
fun setSelectedCategory(category: Category) {
_selectedCategory.update { category }
if (useCategory.value) {
useCategoryFolder()
}
}
private val downloadChecker = DownloadUiChecker(
initialFolder = appSettings.saveLocation.value,
@ -86,6 +128,16 @@ class AddSingleDownloadComponent(
)
.onEachLatest { onDuplicateStrategy.update { null } }
.launchIn(scope)
name.onEach {
val category = categoryManager.getCategoryOfFileName(it)
if (category == null) {
setUseCategory(false)
} else {
setUseCategory(true)
setSelectedCategory(category)
}
}.launchIn(scope)
}
private var wasOpened = false
@ -147,12 +199,14 @@ class AddSingleDownloadComponent(
this.length,
this.speedLimit,
this.threadCount
) { credentials,
folder,
name,
length,
speedLimit,
threadCount ->
) {
credentials,
folder,
name,
length,
speedLimit,
threadCount,
->
DownloadItem(
id = -1,
folder = folder,
@ -245,18 +299,44 @@ class AddSingleDownloadComponent(
fun onRequestDownload() {
val item = downloadItem.value
consumeDialog {
addToLastUsedLocations(item.folder)
onRequestDownload(item, onDuplicateStrategy.value.orDefault())
saveLocationIfNecessary(item.folder)
onRequestDownload(
item,
onDuplicateStrategy.value.orDefault(),
selectedCategory.value?.id
)
}
}
private fun saveLocationIfNecessary(folder: String) {
val category = selectedCategory.value?.takeIf {
useCategory.value
}
val shouldAdd = if (category == null) {
// always add if user don't use category
true
} else {
// only add if category path is not the same as provided path
category.path != folder
}
if (shouldAdd) {
addToLastUsedLocations(folder)
}
}
fun onRequestAddToQueue(
queueId: Long?,
) {
val downloadItem = downloadItem.value
consumeDialog {
addToLastUsedLocations(downloadItem.folder)
onRequestAddToQueue(downloadItem, queueId, onDuplicateStrategy.value.orDefault())
saveLocationIfNecessary(downloadItem.folder)
onRequestAddToQueue(
downloadItem,
queueId,
onDuplicateStrategy.value.orDefault(),
selectedCategory.value?.id
)
}
}
@ -273,19 +353,19 @@ class AddSingleDownloadComponent(
var shouldShowAddToQueue by mutableStateOf(false)
val shouldShowOpenFile = combine(
onDuplicateStrategy,canAddResult,
){onDuplicateStrategy, result ->
if (result is CanAddResult.DownloadAlreadyExists && onDuplicateStrategy==null){
onDuplicateStrategy, canAddResult,
) { onDuplicateStrategy, result ->
if (result is CanAddResult.DownloadAlreadyExists && onDuplicateStrategy == null) {
val item = downloadSystem.getDownloadItemById(result.itemId) ?: return@combine false
if (item.status!=DownloadStatus.Completed){
if (item.status != DownloadStatus.Completed) {
return@combine false
}
downloadSystem.getDownloadFile(item).exists()
}else false
}.stateIn(scope, SharingStarted.WhileSubscribed(),false)
} else false
}.stateIn(scope, SharingStarted.WhileSubscribed(), false)
fun openExistingFile(){
val itemId= (canAddResult.value as? CanAddResult.DownloadAlreadyExists)?.itemId?:return
fun openExistingFile() {
val itemId = (canAddResult.value as? CanAddResult.DownloadAlreadyExists)?.itemId ?: return
consumeDialog {
scope.launch {
downloadItemOpener.openDownloadItem(itemId)
@ -293,4 +373,25 @@ class AddSingleDownloadComponent(
}
}
}
fun addNewCategory() {
onRequestAddCategory()
}
}
fun interface OnRequestAddSingleItem {
operator fun invoke(
item: DownloadItem,
queueId: Long?,
onDuplicateStrategy: OnDuplicateStrategy,
categoryId: Long?,
)
}
fun interface OnRequestDownloadSingleItem {
operator fun invoke(
item: DownloadItem,
onDuplicateStrategy: OnDuplicateStrategy,
categoryId: Long?,
)
}

View File

@ -0,0 +1,108 @@
package com.abdownloadmanager.desktop.pages.category
import arrow.core.split
import com.abdownloadmanager.desktop.repository.AppRepository
import com.abdownloadmanager.desktop.utils.BaseComponent
import com.abdownloadmanager.utils.category.Category
import com.abdownloadmanager.utils.category.CategoryManager
import com.abdownloadmanager.utils.category.iconSource
import com.arkivanov.decompose.ComponentContext
import ir.amirab.util.compose.IconSource
import ir.amirab.util.compose.uriOrNull
import ir.amirab.util.flow.combineStateFlows
import ir.amirab.util.osfileutil.FileUtils
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.File
class CategoryComponent(
ctx: ComponentContext,
val id: Long,
val close: () -> Unit,
private val submit: (Category) -> Unit,
) : BaseComponent(ctx), KoinComponent {
private val appRepository: AppRepository by inject()
val defaultDownloadLocation = appRepository.saveLocation
private val categoryManager: CategoryManager by inject()
init {
if (id >= 0) {
loadCategoryData()
}
}
fun loadCategoryData() {
scope.launch {
val category = categoryManager.getCategoryById(id) ?: return@launch
setIcon(category.iconSource())
setName(category.name)
setTypes(category.acceptedFileTypes.joinToString(" "))
setPath(category.path)
}
}
private val _icon = MutableStateFlow(null as IconSource?)
val icon = _icon.asStateFlow()
fun setIcon(iconSource: IconSource?) {
_icon.value = iconSource
}
private val _name = MutableStateFlow("")
val name = _name.asStateFlow()
fun setName(name: String) {
_name.value = name
}
private val _types = MutableStateFlow("")
val types = _types.asStateFlow()
fun setTypes(types: String) {
_types.value = types
}
private val _path = MutableStateFlow("")
val path = _path.asStateFlow()
fun setPath(path: String) {
_path.value = path
}
val canSubmit = combineStateFlows(
icon,
name,
types,
path
) { icon, name, types, path ->
val iconOk = icon != null
val nameOk = name.isNotBlank()
val pathOk = FileUtils.canWriteInThisFolder(path)
iconOk && nameOk && pathOk
}
val isEditMode = id >= 0
fun submit() {
if (!canSubmit.value) {
return
}
val path = path.value
kotlin.runCatching {
File(path).mkdirs()
}
submit(
Category(
id = id,
name = name.value,
acceptedFileTypes = types.value
.split(" ")
.filterNot { it.isBlank() }
.distinct(),
icon = icon
.value!!
.uriOrNull()!!,
path = path,
items = emptyList() // ignored!
)
)
}
}

View File

@ -0,0 +1,9 @@
package com.abdownloadmanager.desktop.pages.category
import kotlinx.coroutines.flow.StateFlow
interface CategoryDialogManager {
val openedCategoryDialogs: StateFlow<List<CategoryComponent>>
fun openCategoryDialog(categoryId: Long)
fun closeCategoryDialog(categoryId: Long)
}

View File

@ -0,0 +1,381 @@
package com.abdownloadmanager.desktop.pages.category
import androidx.compose.animation.AnimatedVisibility
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.graphics.Color
import androidx.compose.ui.unit.dp
import com.abdownloadmanager.desktop.pages.addDownload.single.MyTextFieldIcon
import com.abdownloadmanager.desktop.ui.customwindow.WindowTitle
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.util.ifThen
import com.abdownloadmanager.desktop.ui.widget.*
import com.abdownloadmanager.desktop.utils.div
import com.abdownloadmanager.utils.compose.WithContentAlpha
import com.abdownloadmanager.utils.compose.widget.MyIcon
import io.github.vinceglb.filekit.compose.rememberDirectoryPickerLauncher
import io.github.vinceglb.filekit.core.FileKitPlatformSettings
import ir.amirab.util.compose.IconSource
import ir.amirab.util.desktop.LocalWindow
import java.io.File
@Composable
fun NewCategory(
categoryComponent: CategoryComponent,
) {
WindowTitle(
if (categoryComponent.isEditMode) "Edit Category"
else "Add Category"
)
Column(
modifier = Modifier
.padding(horizontal = 32.dp)
.padding(vertical = 16.dp)
) {
Column(
Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
) {
Row {
CategoryIcon(
iconSource = categoryComponent.icon.collectAsState().value,
onChange = categoryComponent::setIcon
)
Spacer(Modifier.width(16.dp))
CategoryName(
modifier = Modifier.weight(1f),
name = categoryComponent.name.collectAsState().value,
onNameChanged = categoryComponent::setName
)
}
Spacer(Modifier.height(12.dp))
CategoryAutoTypes(
types = categoryComponent.types.collectAsState().value,
onTypesChanged = categoryComponent::setTypes
)
Spacer(Modifier.height(12.dp))
CategoryDefaultPath(
path = categoryComponent.path.collectAsState().value,
onPathChanged = categoryComponent::setPath,
defaultDownloadLocation = categoryComponent.defaultDownloadLocation.collectAsState().value
)
}
Spacer(Modifier.height(12.dp))
Row(Modifier.fillMaxWidth().wrapContentWidth(Alignment.End)) {
ActionButton(
when (categoryComponent.isEditMode) {
true -> "Change"
false -> "Add"
},
enabled = categoryComponent.canSubmit.collectAsState().value,
onClick = {
categoryComponent.submit()
}
)
Spacer(Modifier.width(8.dp))
ActionButton(
"Cancel",
onClick = {
categoryComponent.close()
}
)
}
}
}
@Composable
fun CategoryDefaultPath(
defaultDownloadLocation: String,
path: String,
onPathChanged: (String) -> Unit,
) {
val initialDirectory = remember(path, defaultDownloadLocation) {
path
.takeIf { it.isNotBlank() }
?.let {
runCatching {
File(path).canonicalPath
}.getOrNull()
} ?: defaultDownloadLocation
}
val downloadFolderPickerLauncher = rememberDirectoryPickerLauncher(
title = "Category Download Location",
initialDirectory = initialDirectory,
platformSettings = FileKitPlatformSettings(
parentWindow = LocalWindow.current
)
) { directory ->
directory?.path?.let(onPathChanged)
}
WithLabel(
"Category Download Location",
helpText = """When this category chosen in "Add Download Page" use this directory as "Download Location"""
) {
CategoryPageTextField(
text = path,
onTextChange = onPathChanged,
modifier = Modifier.fillMaxWidth(),
placeholder = "",
errorText = null,
end = {
MyTextFieldIcon(MyIcons.folder) {
downloadFolderPickerLauncher.launch()
}
}
)
}
}
@Composable
fun CategoryAutoTypes(
types: String,
onTypesChanged: (String) -> Unit,
) {
WithLabel(
label = "Category file types",
helpText = "Automatically put download to these file types to this category. (when you add new download)\n Separate file extensions with space (ext1 ext2 ...) "
) {
CategoryPageTextField(
text = types,
onTextChange = onTypesChanged,
modifier = Modifier.fillMaxWidth(),
placeholder = "ext1 ext2 ext3 (separate with space)",
singleLine = false,
minLines = 2,
maxLines = 2,
)
}
}
@Composable
fun CategoryName(
name: String,
onNameChanged: (String) -> Unit,
modifier: Modifier = Modifier,
) {
WithLabel(
"Category Name",
modifier,
) {
CategoryPageTextField(
text = name,
onTextChange = onNameChanged,
modifier = Modifier.fillMaxWidth(),
placeholder = "Something...",
)
}
}
@Composable
private fun WithLabel(
label: String,
modifier: Modifier = Modifier,
helpText: String? = null,
content: @Composable () -> Unit,
) {
Column(modifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(label)
helpText?.let {
Spacer(Modifier.width(8.dp))
Help(helpText)
}
}
Spacer(Modifier.height(8.dp))
content()
}
}
@Composable
private fun CategoryIcon(
iconSource: IconSource?,
onChange: (IconSource) -> Unit,
) {
var showIconPicker by remember {
mutableStateOf(false)
}
WithLabel(
"Icon"
) {
RenderIcon(
icon = iconSource,
requiresAttention = iconSource == null,
onClick = {
showIconPicker = !showIconPicker
}
)
if (showIconPicker) {
IconPick(
selectedIcon = iconSource,
icons = listOf(
MyIcons.pictureFile,
MyIcons.musicFile,
MyIcons.zipFile,
MyIcons.videoFile,
MyIcons.applicationFile,
MyIcons.documentFile,
MyIcons.otherFile,
MyIcons.file,
MyIcons.folder,
MyIcons.browserIntegration,
MyIcons.appearance,
MyIcons.settings,
MyIcons.search,
MyIcons.info,
MyIcons.check,
MyIcons.link,
MyIcons.download,
MyIcons.speaker,
MyIcons.group,
MyIcons.activeCount,
MyIcons.speed,
MyIcons.resume,
MyIcons.pause,
MyIcons.stop,
MyIcons.queue,
MyIcons.remove,
MyIcons.clear,
MyIcons.add,
MyIcons.paste,
MyIcons.copy,
MyIcons.refresh,
MyIcons.share,
MyIcons.lock,
MyIcons.question,
MyIcons.verticalDirection,
MyIcons.downloadEngine,
MyIcons.network,
MyIcons.externalLink,
),
onSelected = {
onChange(it)
showIconPicker = false
},
onCancel = {
showIconPicker = false
}
)
}
}
}
@Composable
private fun RenderIcon(
icon: IconSource?,
indicateActive: Boolean = false,
requiresAttention: Boolean = false,
onClick: () -> Unit,
) {
val shape = RoundedCornerShape(10.dp)
Box(
Modifier
.border(
1.dp,
myColors.onBackground / 10,
shape
)
.ifThen(indicateActive || requiresAttention) {
border(
1.dp,
myColors.primary / if (indicateActive) 1f else alphaFlicker(),
shape
)
}
.clip(shape)
.background(myColors.surface)
.clickable {
onClick()
}
.padding(6.dp)
) {
val modifier = Modifier
.size(20.dp)
if (icon != null) {
MyIcon(
icon,
null,
modifier,
)
} else {
Spacer(modifier)
}
}
}
@Composable
private fun CategoryPageTextField(
text: String,
onTextChange: (String) -> Unit,
placeholder: String,
modifier: Modifier,
errorText: String? = null,
singleLine: Boolean = true,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
minLines: Int = 1,
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 = text,
onTextChange = onTextChange,
placeholder = placeholder,
modifier = Modifier.fillMaxWidth(),
maxLines = maxLines,
minLines = minLines,
singleLine = singleLine,
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,
)
}
}
}
}

View File

@ -0,0 +1,34 @@
package com.abdownloadmanager.desktop.pages.category
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.rememberWindowState
import com.abdownloadmanager.desktop.ui.customwindow.CustomWindow
@Composable
fun ShowCategoryDialogs(dialogManager: CategoryDialogManager) {
val dialogs by dialogManager.openedCategoryDialogs.collectAsState()
for (d in dialogs) {
CustomWindow(
onCloseRequest = {
d.close()
},
alwaysOnTop = true,
state = rememberWindowState(
size = DpSize(350.dp, 350.dp)
)
) {
CategoryDialog(d)
}
}
}
@Composable
private fun CategoryDialog(
component: CategoryComponent,
) {
NewCategory(component)
}

View File

@ -5,7 +5,6 @@ import com.abdownloadmanager.desktop.actions.*
import com.abdownloadmanager.desktop.pages.home.sections.DownloadListCells
import com.abdownloadmanager.desktop.pages.home.sections.category.DefinedStatusCategories
import com.abdownloadmanager.desktop.pages.home.sections.category.DownloadStatusCategoryFilter
import com.abdownloadmanager.desktop.pages.home.sections.category.DownloadTypeCategoryFilter
import com.abdownloadmanager.desktop.storage.PageStatesStorage
import com.abdownloadmanager.desktop.ui.icon.MyIcons
import com.abdownloadmanager.desktop.ui.widget.NotificationType
@ -21,7 +20,12 @@ import androidx.compose.runtime.*
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.abdownloadmanager.desktop.pages.category.CategoryDialogManager
import com.abdownloadmanager.desktop.storage.AppSettingsStorage
import com.abdownloadmanager.utils.FileIconProvider
import com.abdownloadmanager.utils.category.Category
import com.abdownloadmanager.utils.category.CategoryManager
import com.abdownloadmanager.utils.category.DefaultCategories
import com.arkivanov.decompose.ComponentContext
import ir.amirab.downloader.downloaditem.DownloadCredentials
import ir.amirab.downloader.downloaditem.DownloadJobStatus
@ -32,6 +36,7 @@ import ir.amirab.util.flow.combineStateFlows
import ir.amirab.util.flow.mapStateFlow
import ir.amirab.util.flow.mapTwoWayStateFlow
import com.abdownloadmanager.utils.extractors.linkextractor.DownloadCredentialFromStringExtractor
import ir.amirab.util.osfileutil.FileUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@ -43,7 +48,7 @@ import java.net.URI
@Stable
class FilterState {
var textToSearch by mutableStateOf("")
var typeCategoryFilter by mutableStateOf(null as DownloadTypeCategoryFilter?)
var typeCategoryFilter by mutableStateOf(null as Category?)
var statusFilter by mutableStateOf<DownloadStatusCategoryFilter>(DefinedStatusCategories.All)
}
@ -53,6 +58,13 @@ sealed interface HomeEffects {
data class DeleteItems(
val list: List<Long>,
) : HomeEffects
data class DeleteCategory(
val category: Category,
) : HomeEffects
data object ResetCategoriesToDefault : HomeEffects
data object AutoCategorize : HomeEffects
}
@ -63,6 +75,7 @@ class DownloadActions(
val selections: StateFlow<List<IDownloadItemState>>,
private val mainItem: StateFlow<Long?>,
private val queueManager: QueueManager,
private val categoryManager: CategoryManager,
private val openFile: (Long) -> Unit,
private val openFolder: (Long) -> Unit,
private val requestDelete: (List<Long>) -> Unit,
@ -214,6 +227,28 @@ class DownloadActions(
setItems(list)
}.launchIn(scope)
}
private val moveToCategoryAction = MenuItem.SubMenu(
title = "Move To Category",
items = emptyList()
).apply {
merge(
categoryManager.categoriesFlow.mapStateFlow {
it.map(Category::id)
},
selections
).onEach {
val categories = categoryManager.categoriesFlow.value
val list = categories.map { category ->
createMoveToCategoryAction(
category = category,
itemIds = selections.value.map { iDownloadItemState ->
iDownloadItemState.id
}
)
}
setItems(list)
}.launchIn(scope)
}
val menu: List<MenuItem> = buildMenu {
@ -226,17 +261,123 @@ class DownloadActions(
+(reDownloadAction)
separator()
+moveToQueueItems
+moveToCategoryAction
separator()
+(copyDownloadLinkAction)
+(openDownloadDialogAction)
}
}
@Stable
class CategoryActions(
private val scope: CoroutineScope,
private val categoryManager: CategoryManager,
private val defaultCategories: DefaultCategories,
val categoryItem: Category?,
private val openFolder: (Category) -> Unit,
private val requestDelete: (Category) -> Unit,
private val requestEdit: (Category) -> Unit,
private val onRequestResetToDefaults: () -> Unit,
private val onRequestCategorizeItems: () -> Unit,
private val onRequestAddCategory: () -> Unit,
) {
private val mainItemExists = MutableStateFlow(categoryItem != null)
private inline fun useItem(
block: (Category) -> Unit,
) {
categoryItem?.let(block)
}
val openCategoryFolderAction = simpleAction(
title = "Open Folder",
icon = MyIcons.folderOpen,
checkEnable = mainItemExists,
onActionPerformed = {
scope.launch {
useItem {
openFolder(it)
}
}
}
)
val deleteAction = simpleAction(
title = "Delete Category",
icon = MyIcons.remove,
checkEnable = mainItemExists,
onActionPerformed = {
scope.launch {
useItem {
requestDelete(it)
}
}
},
)
val editAction = simpleAction(
title = "Edit Category",
icon = MyIcons.settings,
checkEnable = mainItemExists,
onActionPerformed = {
scope.launch {
useItem {
requestEdit(it)
}
}
},
)
val addCategoryAction = simpleAction(
title = "Add Category",
icon = MyIcons.add,
onActionPerformed = {
scope.launch {
onRequestAddCategory()
}
},
)
val categorizeItemsAction = simpleAction(
title = "Auto Categorise Items",
icon = MyIcons.refresh,
onActionPerformed = {
scope.launch {
onRequestCategorizeItems()
}
},
)
val resetToDefaultAction = simpleAction(
title = "Restore Defaults",
icon = MyIcons.undo,
checkEnable = categoryManager
.categoriesFlow
.mapStateFlow { !defaultCategories.isDefault(it) },
onActionPerformed = {
scope.launch {
onRequestResetToDefaults()
}
},
)
val menu: List<MenuItem> = buildMenu {
+editAction
+openCategoryFolderAction
+deleteAction
separator()
+addCategoryAction
separator()
+categorizeItemsAction
+resetToDefaultAction
}
}
class HomeComponent(
ctx: ComponentContext,
private val downloadItemOpener: DownloadItemOpener,
private val downloadDialogManager: DownloadDialogManager,
private val addDownloadDialogManager: AddDownloadDialogManager,
private val categoryDialogManager: CategoryDialogManager,
private val notificationSender: NotificationSender,
) : BaseComponent(ctx),
ContainsShortcuts,
@ -251,6 +392,10 @@ class HomeComponent(
private val homePageStateToPersist = MutableStateFlow(pageStorage.homePageStorage.value)
val categoryManager: CategoryManager by inject()
private val defaultCategories: DefaultCategories by inject()
val fileIconProvider: FileIconProvider by inject()
init {
homePageStateToPersist
.debounce(500)
@ -296,6 +441,12 @@ class HomeComponent(
sendEffect(HomeEffects.DeleteItems(downloadList))
}
fun onConfirmDeleteCategory(promptState: CategoryDeletePromptState) {
scope.launch {
categoryManager.deleteCategory(promptState.category)
}
}
fun confirmDelete(promptState: DeletePromptState) {
scope.launch {
val selectionList = promptState.downloadList
@ -305,6 +456,25 @@ class HomeComponent(
}
}
fun onConfirmAutoCategorize() {
val categorizedItems = categoryManager.getCategories()
.flatMap { it.items }
val allDownloads = activeDownloadList.value + completedList.value
val unCategorizedItems = allDownloads.filterNot {
it.id in categorizedItems
}
categoryManager
.autoAddItemsToCategoriesBasedOnFileNames(
unCategorizedItems.map { it.id to it.name }
)
}
fun onConfirmResetCategories() {
scope.launch {
categoryManager.reset()
}
}
fun requestAddNewDownload(
link: List<DownloadCredentials> = listOf(DownloadCredentials.empty()),
@ -370,7 +540,6 @@ class HomeComponent(
}.filterIsInstance<MenuItem.SubMenu>()
private val shouldShowOptions = MutableStateFlow(false)
val downloadOptions = combineStateFlows(
shouldShowOptions,
@ -465,7 +634,7 @@ class HomeComponent(
fun onFilterChange(
statusCategoryFilter: DownloadStatusCategoryFilter,
typeCategoryFilter: DownloadTypeCategoryFilter?,
typeCategoryFilter: Category?,
) {
this.filterState.statusFilter = statusCategoryFilter
this.filterState.typeCategoryFilter = typeCategoryFilter
@ -546,6 +715,14 @@ class HomeComponent(
emptyList()
)
init {
categoryManager.categoriesFlow.onEach { categories ->
val currentCategory = filterState.typeCategoryFilter ?: return@onEach
filterState.typeCategoryFilter = categories.find {
it.id == currentCategory.id
}
}.launchIn(scope)
}
val downloadList = merge(
snapshotFlow { filterState.textToSearch },
@ -558,7 +735,8 @@ class HomeComponent(
(activeDownloadList.value + completedList.value)
.filter {
val statusAccepted = filterState.statusFilter.accept(it)
val typeAccepted = filterState.typeCategoryFilter?.accept(it) ?: true
// val typeAccepted = filterState.typeCategoryFilter?.accept(it.name) ?: true
val typeAccepted = filterState.typeCategoryFilter?.items?.contains(it.id) ?: true
val searchAccepted = it.name.contains(filterState.textToSearch, ignoreCase = true)
typeAccepted && statusAccepted && searchAccepted
}
@ -630,16 +808,54 @@ class HomeComponent(
private val downloadActions = DownloadActions(
scope,
downloadSystem,
downloadDialogManager,
selectionListItems,
mainItem,
queueManager,
this::openFile,
this::openFolder,
this::requestDelete,
scope = scope,
downloadSystem = downloadSystem,
downloadDialogManager = downloadDialogManager,
selections = selectionListItems,
mainItem = mainItem,
queueManager = queueManager,
categoryManager = categoryManager,
openFile = this::openFile,
openFolder = this::openFolder,
requestDelete = this::requestDelete,
)
val categoryActions = MutableStateFlow(null as CategoryActions?)
fun showCategoryOptions(categoryItem: Category?) {
categoryActions.value = CategoryActions(
scope = scope,
categoryManager = categoryManager,
defaultCategories = defaultCategories,
categoryItem = categoryItem,
openFolder = {
runCatching {
FileUtils.openFolder(File(it.path))
}
},
onRequestAddCategory = {
categoryDialogManager.openCategoryDialog(-1)
},
requestDelete = {
sendEffect(
HomeEffects.DeleteCategory(it)
)
},
requestEdit = {
categoryDialogManager.openCategoryDialog(it.id)
},
onRequestCategorizeItems = {
sendEffect(HomeEffects.AutoCategorize)
},
onRequestResetToDefaults = {
sendEffect(HomeEffects.ResetCategoriesToDefault)
}
)
}
fun closeCategoryOptions() {
categoryActions.value = null
}
override val shortcutManager = ShortcutManager().apply {
"ctrl N" to newDownloadAction
"ctrl V" to newDownloadFromClipboardAction

View File

@ -40,7 +40,11 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.window.Dialog
import com.abdownloadmanager.desktop.ui.customwindow.*
import com.abdownloadmanager.desktop.ui.widget.menu.ShowOptionsInDropDown
import com.abdownloadmanager.desktop.utils.externaldraggable.DragData
import com.abdownloadmanager.utils.category.Category
import com.abdownloadmanager.utils.category.rememberIconPainter
import ir.amirab.util.compose.action.MenuItem
@Composable
@ -53,6 +57,14 @@ fun HomePage(component: HomeComponent) {
mutableStateOf(null as DeletePromptState?)
}
var showDeleteCategoryPromptState by remember {
mutableStateOf(null as CategoryDeletePromptState?)
}
var showConfirmPrompt by remember {
mutableStateOf(null as ConfirmPromptState?)
}
LaunchedEffect(Unit) {
component.effects.onEach {
when (it) {
@ -62,6 +74,26 @@ fun HomePage(component: HomeComponent) {
}
}
is HomeEffects.DeleteCategory -> {
showDeleteCategoryPromptState = CategoryDeletePromptState(it.category)
}
is HomeEffects.AutoCategorize -> {
showConfirmPrompt = ConfirmPromptState(
title = "Auto categorize downloads",
description = "Any uncategorized item will be automatically added to it's related category.",
onConfirm = component::onConfirmAutoCategorize
)
}
is HomeEffects.ResetCategoriesToDefault -> {
showConfirmPrompt = ConfirmPromptState(
title = "Reset to Default Categories",
description = "this will REMOVE all categories and brings backs default categories",
onConfirm = component::onConfirmResetCategories
)
}
else -> {}
}
}
@ -78,6 +110,29 @@ fun HomePage(component: HomeComponent) {
component.confirmDelete(it)
})
}
showDeleteCategoryPromptState?.let {
ShowDeleteCategoryPrompt(
deletePromptState = it,
onCancel = {
showDeleteCategoryPromptState = null
},
onConfirm = {
showDeleteCategoryPromptState = null
component.onConfirmDeleteCategory(it)
})
}
showConfirmPrompt?.let {
ShowConfirmPrompt(
promptState = it,
onCancel = {
showConfirmPrompt = null
},
onConfirm = {
showConfirmPrompt?.onConfirm?.invoke()
showConfirmPrompt = null
}
)
}
val mergeTopBar = shouldMergeTopBarWithTitleBar(component)
if (mergeTopBar) {
WindowTitlePosition(
@ -147,8 +202,9 @@ fun HomePage(component: HomeComponent) {
Row() {
val categoriesWidth by component.categoriesWidth.collectAsState()
Categories(
Modifier.padding(top = 8.dp)
.width(categoriesWidth), component
modifier = Modifier.padding(top = 8.dp)
.width(categoriesWidth),
component = component
)
Spacer(Modifier.size(8.dp))
//split pane
@ -195,6 +251,8 @@ fun HomePage(component: HomeComponent) {
},
lastSelectedId = lastSelected,
tableState = component.tableState,
fileIconProvider = component.fileIconProvider,
categoryManager = component.categoryManager,
)
Spacer(
Modifier
@ -315,6 +373,122 @@ private fun ShowDeletePrompts(
}
}
@Composable
private fun ShowConfirmPrompt(
promptState: ConfirmPromptState,
onConfirm: () -> Unit,
onCancel: () -> Unit,
) {
val shape = RoundedCornerShape(6.dp)
Dialog(onDismissRequest = onCancel) {
Column(
Modifier
.clip(shape)
.border(2.dp, myColors.onBackground / 10, shape)
.background(
Brush.linearGradient(
listOf(
myColors.surface,
myColors.background,
)
)
)
.padding(16.dp)
.width(IntrinsicSize.Max)
.widthIn(max = 260.dp)
) {
Text(
text = promptState.title,
fontWeight = FontWeight.Bold,
fontSize = myTextSizes.xl,
color = myColors.onBackground,
)
Spacer(Modifier.height(12.dp))
Text(
text = promptState.description,
fontSize = myTextSizes.base,
color = myColors.onBackground,
)
Spacer(Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(Modifier.weight(1f))
ActionButton(
text = "Delete",
onClick = onConfirm,
borderColor = SolidColor(myColors.error),
contentColor = myColors.error,
)
Spacer(Modifier.width(8.dp))
ActionButton(text = "Cancel", onClick = onCancel)
}
}
}
}
@Composable
private fun ShowDeleteCategoryPrompt(
deletePromptState: CategoryDeletePromptState,
onConfirm: () -> Unit,
onCancel: () -> Unit,
) {
val shape = RoundedCornerShape(6.dp)
Dialog(onDismissRequest = onCancel) {
Column(
Modifier
.clip(shape)
.border(2.dp, myColors.onBackground / 10, shape)
.background(
Brush.linearGradient(
listOf(
myColors.surface,
myColors.background,
)
)
)
.padding(16.dp)
.width(IntrinsicSize.Max)
.widthIn(max = 260.dp)
) {
Text(
"""Removing "${deletePromptState.category.name}" Category""",
fontWeight = FontWeight.Bold,
fontSize = myTextSizes.xl,
color = myColors.onBackground,
)
Spacer(Modifier.height(12.dp))
Text(
"""Are you sure you want to delete "${deletePromptState.category.name}" Category ?""",
fontSize = myTextSizes.base,
color = myColors.onBackground,
)
Spacer(Modifier.height(12.dp))
Text(
"Your downloads won't be deleted",
fontSize = myTextSizes.base,
color = myColors.onBackground,
)
Spacer(Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(Modifier.weight(1f))
ActionButton(
text = "Delete",
onClick = onConfirm,
borderColor = SolidColor(myColors.error),
contentColor = myColors.error,
)
Spacer(Modifier.width(8.dp))
ActionButton(text = "Cancel", onClick = onCancel)
}
}
}
}
@Stable
class DeletePromptState(
val downloadList: List<Long>,
@ -322,6 +496,18 @@ class DeletePromptState(
var alsoDeleteFile by mutableStateOf(false)
}
@Immutable
data class CategoryDeletePromptState(
val category: Category,
)
@Immutable
data class ConfirmPromptState(
val title: String,
val description: String,
val onConfirm: () -> Unit,
)
@Composable
fun DragWidget(
modifier: Modifier,
@ -375,15 +561,26 @@ fun DragWidget(
}
@Composable
private fun Categories(
modifier: Modifier,
component: HomeComponent,
) {
val currentTypeFilter = component.filterState.typeCategoryFilter
val currentStatusFilter = component.filterState.statusFilter
val categories by component.categoryManager.categoriesFlow.collectAsState()
val clipShape = RoundedCornerShape(12.dp)
val showCategoryOption by component.categoryActions.collectAsState()
fun showCategoryOption(item: Category?) {
component.showCategoryOptions(item)
}
fun closeCategoryOptions() {
component.closeCategoryOptions()
}
Column(
modifier
.padding(start = 16.dp)
@ -398,16 +595,42 @@ private fun Categories(
currentTypeCategoryFilter = currentTypeFilter,
currentStatusCategoryFilter = currentStatusFilter,
statusFilter = statusCategoryFilter,
typeFilter = DefinedTypeCategories.values(),
categories = categories,
onFilterChange = {
component.onFilterChange(statusCategoryFilter, it)
},
onRequestExpand = { expand ->
expendedItem = statusCategoryFilter.takeIf { expand }
},
onRequestOpenOptionMenu = {
showCategoryOption(it)
}
)
}
}
showCategoryOption?.let {
CategoryOption(
categoryOptionMenuState = it,
onDismiss = {
closeCategoryOptions()
}
)
}
}
@Composable
fun CategoryOption(
categoryOptionMenuState: CategoryActions,
onDismiss: () -> Unit,
) {
ShowOptionsInDropDown(
MenuItem.SubMenu(
icon = categoryOptionMenuState.categoryItem?.rememberIconPainter(),
title = categoryOptionMenuState.categoryItem?.name.orEmpty(),
categoryOptionMenuState.menu,
),
onDismiss
)
}
@Composable

View File

@ -25,6 +25,9 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.*
import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.unit.dp
import com.abdownloadmanager.utils.FileIconProvider
import com.abdownloadmanager.utils.category.CategoryManager
import com.abdownloadmanager.utils.category.rememberCategoryOf
import ir.amirab.downloader.monitor.IDownloadItemState
import ir.amirab.downloader.monitor.remainingOrNull
import ir.amirab.downloader.monitor.speedOrNull
@ -63,6 +66,8 @@ fun DownloadList(
onRequestOpenDownload: (Long) -> Unit,
onNewSelection: (List<Long>) -> Unit,
lastSelectedId: Long?,
fileIconProvider: FileIconProvider,
categoryManager: CategoryManager,
) {
val state = rememberLazyListState()
ShowDownloadOptions(
@ -249,7 +254,11 @@ fun DownloadList(
}
DownloadListCells.Name -> {
NameCell(item)
NameCell(
itemState = item,
category = categoryManager.rememberCategoryOf(item.id),
fileIconProvider = fileIconProvider,
)
}
DownloadListCells.DateAdded -> {

View File

@ -1,7 +1,6 @@
package com.abdownloadmanager.desktop.pages.home.sections
import com.abdownloadmanager.desktop.pages.home.sections.SortIndicatorMode.*
import com.abdownloadmanager.desktop.pages.home.sections.category.DefinedTypeCategories
import com.abdownloadmanager.utils.compose.LocalContentColor
import com.abdownloadmanager.utils.compose.widget.MyIcon
import com.abdownloadmanager.desktop.ui.theme.myColors
@ -20,6 +19,8 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.abdownloadmanager.utils.FileIconProvider
import com.abdownloadmanager.utils.category.Category
import ir.amirab.downloader.downloaditem.DownloadJobStatus
import ir.amirab.downloader.monitor.CompletedDownloadItemState
import ir.amirab.downloader.monitor.IDownloadItemState
@ -30,7 +31,7 @@ import kotlinx.coroutines.isActive
import kotlinx.datetime.*
val LocalDownloadItemProperties =
compositionLocalOf<DownloadItemProperties> { error("not provided download properties") }
compositionLocalOf<DownloadItemProperties> { error("not provided download properties") }
data class DownloadItemProperties(
@ -91,16 +92,16 @@ fun CheckCell(
@Composable
fun NameCell(
itemState: IDownloadItemState
itemState: IDownloadItemState,
category: Category?,
fileIconProvider: FileIconProvider,
) {
val typeCategoryFilter = remember(itemState.id) {
DefinedTypeCategories.resolveCategoryForDownloadItem(itemState)
}
val fileIcon = fileIconProvider.rememberIcon(itemState.name)
Row(
verticalAlignment = Alignment.CenterVertically
) {
MyIcon(
icon = typeCategoryFilter.icon,
icon = fileIcon,
modifier = Modifier.size(16.dp),
contentDescription = null,
// tint = LocalContentColor.current / 75
@ -113,7 +114,8 @@ fun NameCell(
fontSize = myTextSizes.base,
overflow = TextOverflow.Ellipsis,
)
Text(typeCategoryFilter.name, maxLines = 1, fontSize = myTextSizes.xs,
Text(
category?.name ?: "General", maxLines = 1, fontSize = myTextSizes.xs,
color = LocalContentColor.current / 50
)
}
@ -203,9 +205,9 @@ fun StatusCell(
ProgressAndPercent(
itemState.percent,
if (ExceptionUtils.isNormalCancellation(status.e)) {
if (!itemState.gotAnyProgress){
if (!itemState.gotAnyProgress) {
DownloadProgressStatus.Added
}else{
} else {
DownloadProgressStatus.Paused
}
} else {
@ -218,9 +220,9 @@ fun StatusCell(
DownloadJobStatus.IDLE -> {
ProgressAndPercent(
itemState.percent,
if (!itemState.gotAnyProgress){
if (!itemState.gotAnyProgress) {
DownloadProgressStatus.Added
}else{
} else {
DownloadProgressStatus.Paused
},
itemState.gotAnyProgress
@ -245,7 +247,7 @@ fun StatusCell(
DownloadJobStatus.Finished,
DownloadJobStatus.Resuming,
-> SimpleStatus(itemState.status.toString())
-> SimpleStatus(itemState.status.toString())
}
}
@ -275,22 +277,22 @@ private enum class DownloadProgressStatus {
private fun ProgressAndPercent(
percent: Int?,
status: DownloadProgressStatus,
gotAnyProgress:Boolean,
gotAnyProgress: Boolean,
) {
val background = when (status) {
DownloadProgressStatus.Error -> myColors.errorGradient
DownloadProgressStatus.Paused,DownloadProgressStatus.Added -> myColors.warningGradient
DownloadProgressStatus.Paused, DownloadProgressStatus.Added -> myColors.warningGradient
DownloadProgressStatus.CreatingFile -> myColors.infoGradient
DownloadProgressStatus.Downloading -> myColors.primaryGradient
}
Column {
val statusText = if (gotAnyProgress){
val statusText = if (gotAnyProgress) {
"${percent ?: "."}% $status"
}else{
} else {
"$status"
}
SimpleStatus(statusText)
if (status != DownloadProgressStatus.Added){
if (status != DownloadProgressStatus.Added) {
Spacer(Modifier.height(2.5.dp))
ProgressStatus(
percent, background

View File

@ -1,5 +1,6 @@
package com.abdownloadmanager.desktop.pages.home.sections.category
import androidx.compose.foundation.PointerMatcher
import ir.amirab.util.compose.IconSource
import com.abdownloadmanager.utils.compose.widget.MyIcon
import com.abdownloadmanager.desktop.ui.icon.MyIcons
@ -8,43 +9,25 @@ import com.abdownloadmanager.desktop.ui.widget.ExpandableItem
import com.abdownloadmanager.utils.compose.WithContentAlpha
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.onClick
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape
import com.abdownloadmanager.desktop.ui.widget.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.input.pointer.PointerButton
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.abdownloadmanager.utils.category.Category
import com.abdownloadmanager.utils.category.rememberIconPainter
import ir.amirab.downloader.downloaditem.DownloadStatus
import ir.amirab.downloader.monitor.IDownloadItemState
import ir.amirab.downloader.monitor.statusOrFinished
abstract class DownloadTypeCategoryFilter(
val name: String,
val icon: IconSource,
) {
abstract fun accept(iDownloadStatus: IDownloadItemState): Boolean
}
class DownloadTypeCategoryFilterByList(
name: String,
icon: IconSource,
acceptedTypes: List<String>,
) : DownloadTypeCategoryFilter(name,icon) {
val acceptedTypes = acceptedTypes.map { it.lowercase() }
override fun accept(iDownloadStatus: IDownloadItemState): Boolean {
val extension = iDownloadStatus.name
.split(".")
.lastOrNull()
?.lowercase() ?: return false
return extension in acceptedTypes
}
}
class DownloadStatusCategoryFilterByList(
name: String,
icon: IconSource,
@ -91,61 +74,11 @@ object DefinedStatusCategories {
)
}
object DefinedTypeCategories {
fun values() = listOf(
Image, Music, Video, App, Document, Compressed, Other
)
fun resolveCategoryForDownloadItem(item: IDownloadItemState): DownloadTypeCategoryFilter {
return values().first {
it.accept(item)
}
}
val Image = DownloadTypeCategoryFilterByList(
"Image",
MyIcons.pictureFile,
listOf("png", "jpg", "jpeg", "gif", "svg")
)
val Music = DownloadTypeCategoryFilterByList(
"Music",
MyIcons.musicFile,
listOf("mp3")
)
val Video = DownloadTypeCategoryFilterByList(
"Video",
MyIcons.videoFile,
listOf("mp4", "mkv", "3gp", "avi")
)
val App = DownloadTypeCategoryFilterByList(
"Apps",
MyIcons.applicationFile,
listOf("apk", "deb", "exe", "msi", "jar")
)
val Document = DownloadTypeCategoryFilterByList(
"Document",
MyIcons.documentFile,
listOf("txt", "docx", "pdf")
)
val Compressed = DownloadTypeCategoryFilterByList(
"Compressed",
MyIcons.zipFile,
listOf("zip", "rar", "tz")
)
val Other = object : DownloadTypeCategoryFilter(
"Other",
MyIcons.otherFile,
) {
override fun accept(iDownloadStatus: IDownloadItemState): Boolean =true
}
}
@Composable
private fun CategoryFilterItem(
modifier: Modifier,
category: DownloadTypeCategoryFilter,
category: Category,
isSelected: Boolean,
onClick: () -> Unit,
) {
@ -156,13 +89,13 @@ private fun CategoryFilterItem(
onClick = onClick
)
.padding(start = 24.dp)
.padding(horizontal = 4.dp,vertical = 6.dp)
,
.padding(horizontal = 4.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
WithContentAlpha(if (isSelected)1f else 0.75f){
WithContentAlpha(if (isSelected) 1f else 0.75f) {
val iconPainter = category.rememberIconPainter()
MyIcon(
category.icon,
iconPainter ?: MyIcons.folder,
null,
Modifier.size(16.dp),
)
@ -182,17 +115,24 @@ private fun CategoryFilterItem(
fun StatusFilterItem(
isExpanded: Boolean,
onRequestExpand: (Boolean) -> Unit,
currentTypeCategoryFilter: DownloadTypeCategoryFilter?,
currentTypeCategoryFilter: Category?,
currentStatusCategoryFilter: DownloadStatusCategoryFilter?,
statusFilter: DownloadStatusCategoryFilter,
typeFilter: List<DownloadTypeCategoryFilter>,
categories: List<Category>,
onFilterChange: (
typeFilter: DownloadTypeCategoryFilter?,
typeFilter: Category?,
) -> Unit,
onRequestOpenOptionMenu: (Category?) -> Unit,
) {
val isStatusSelected = currentStatusCategoryFilter == statusFilter
val isSelected = isStatusSelected && currentTypeCategoryFilter == null
ExpandableItem(
modifier = Modifier
.onClick(
matcher = PointerMatcher.mouse(PointerButton.Secondary),
) {
onRequestOpenOptionMenu(null)
},
isExpanded = isExpanded,
header = {
Row(
@ -242,9 +182,13 @@ fun StatusFilterItem(
},
body = {
Column(Modifier) {
typeFilter.forEach {
categories.forEach {
CategoryFilterItem(
modifier = Modifier,
modifier = Modifier.onClick(
matcher = PointerMatcher.mouse(PointerButton.Secondary),
) {
onRequestOpenOptionMenu(it)
},
category = it,
isSelected = isStatusSelected && currentTypeCategoryFilter == it,
onClick = {

View File

@ -26,6 +26,7 @@ import com.abdownloadmanager.desktop.utils.mvi.HandleEffects
import androidx.compose.runtime.*
import androidx.compose.ui.window.*
import com.abdownloadmanager.desktop.pages.batchdownload.BatchDownloadWindow
import com.abdownloadmanager.desktop.pages.category.ShowCategoryDialogs
import com.abdownloadmanager.desktop.pages.home.HomeWindow
import com.abdownloadmanager.desktop.pages.settings.ThemeManager
import com.abdownloadmanager.utils.compose.ProvideDebugInfo
@ -78,6 +79,7 @@ object Ui : KoinComponent {
}
ShowAddDownloadDialogs(appComponent)
ShowDownloadDialogs(appComponent)
ShowCategoryDialogs(appComponent)
//TODO Enable Updater
//ShowUpdaterDialog(appComponent.updater)
ShowAboutDialog(appComponent)

View File

@ -5,89 +5,89 @@ import ir.amirab.util.compose.IconSource
import com.abdownloadmanager.utils.compose.asIconSource
object MyIcons : IMyIcons {
override val appIcon: IconSource get() = "icons/app_icon.svg".asIconSource(false)
override val appIcon: IconSource get() = "/icons/app_icon.svg".asIconSource(false)
override val settings get() = "icons/settings.svg".asIconSource()
override val search get() = "icons/search.svg".asIconSource()
override val info get() = "icons/info.svg".asIconSource()
override val check get() = "icons/check.svg".asIconSource()
override val link get() = "icons/add_link.svg".asIconSource()
override val download get() = "icons/down_speed.svg".asIconSource()
override val settings get() = "/icons/settings.svg".asIconSource()
override val search get() = "/icons/search.svg".asIconSource()
override val info get() = "/icons/info.svg".asIconSource()
override val check get() = "/icons/check.svg".asIconSource()
override val link get() = "/icons/add_link.svg".asIconSource()
override val download get() = "/icons/down_speed.svg".asIconSource()
override val windowMinimize get() = "icons/window_minimize.svg".asIconSource()
override val windowFloating get() = "icons/window_floating.svg".asIconSource()
override val windowMaximize get() = "icons/window_maximize.svg".asIconSource()
override val windowClose get() = "icons/window_close.svg".asIconSource()
override val windowMinimize get() = "/icons/window_minimize.svg".asIconSource()
override val windowFloating get() = "/icons/window_floating.svg".asIconSource()
override val windowMaximize get() = "/icons/window_maximize.svg".asIconSource()
override val windowClose get() = "/icons/window_close.svg".asIconSource()
override val exit get() = "icons/exit.svg".asIconSource()
override val undo get() = "icons/undo.svg".asIconSource()
override val exit get() = "/icons/exit.svg".asIconSource()
override val undo get() = "/icons/undo.svg".asIconSource()
override val openSource: IconSource get() = "icons/open_source.svg".asIconSource()
override val telegram: IconSource get() = "icons/telegram.svg".asIconSource(false)
override val speaker: IconSource get() = "icons/speaker.svg".asIconSource()
override val group: IconSource get() = "icons/group.svg".asIconSource()
override val openSource: IconSource get() = "/icons/open_source.svg".asIconSource()
override val telegram: IconSource get() = "/icons/telegram.svg".asIconSource(false)
override val speaker: IconSource get() = "/icons/speaker.svg".asIconSource()
override val group: IconSource get() = "/icons/group.svg".asIconSource()
override val browserMozillaFirefox: IconSource get() = "icons/browser_mozilla_firefox.svg".asIconSource(false)
override val browserGoogleChrome: IconSource get() = "icons/browser_google_chrome.svg".asIconSource(false)
override val browserMicrosoftEdge: IconSource get() = "icons/browser_microsoft_edge.svg".asIconSource(false)
override val browserOpera: IconSource get() = "icons/browser_opera.svg".asIconSource(false)
override val browserMozillaFirefox: IconSource get() = "/icons/browser_mozilla_firefox.svg".asIconSource(false)
override val browserGoogleChrome: IconSource get() = "/icons/browser_google_chrome.svg".asIconSource(false)
override val browserMicrosoftEdge: IconSource get() = "/icons/browser_microsoft_edge.svg".asIconSource(false)
override val browserOpera: IconSource get() = "/icons/browser_opera.svg".asIconSource(false)
// override val menu get() = TablerIcons.Menu.asIconSource()
// override val menuClose get() = TablerIcons.X.asIconSource()
override val next get() = "icons/next.svg".asIconSource()
override val next get() = "/icons/next.svg".asIconSource()
// override val back get() = TablerIcons.ChevronLeft.asIconSource()
override val back get() = "icons/back.svg".asIconSource()
override val up get() = "icons/up.svg".asIconSource()
override val down get() = "icons/down.svg".asIconSource()
override val back get() = "/icons/back.svg".asIconSource()
override val up get() = "/icons/up.svg".asIconSource()
override val down get() = "/icons/down.svg".asIconSource()
override val activeCount get() = "icons/list.svg".asIconSource()
override val speed get() = "icons/down_speed.svg".asIconSource()
override val activeCount get() = "/icons/list.svg".asIconSource()
override val speed get() = "/icons/down_speed.svg".asIconSource()
override val resume get() = "icons/resume.svg".asIconSource()
override val pause get() = "icons/pause.svg".asIconSource()
override val stop get() = "icons/stop.svg".asIconSource()
override val resume get() = "/icons/resume.svg".asIconSource()
override val pause get() = "/icons/pause.svg".asIconSource()
override val stop get() = "/icons/stop.svg".asIconSource()
override val queue get() = "icons/queue.svg".asIconSource()
override val queue get() = "/icons/queue.svg".asIconSource()
override val remove get() = "icons/delete.svg".asIconSource()
override val clear get() = "icons/clear.svg".asIconSource()
override val add get() = "icons/plus.svg".asIconSource()
override val paste get() = "icons/clipboard.svg".asIconSource()
override val remove get() = "/icons/delete.svg".asIconSource()
override val clear get() = "/icons/clear.svg".asIconSource()
override val add get() = "/icons/plus.svg".asIconSource()
override val paste get() = "/icons/clipboard.svg".asIconSource()
override val copy get() = "icons/copy.svg".asIconSource()
override val refresh get() = "icons/refresh.svg".asIconSource()
override val editFolder get() = "icons/folder.svg".asIconSource()
override val copy get() = "/icons/copy.svg".asIconSource()
override val refresh get() = "/icons/refresh.svg".asIconSource()
override val editFolder get() = "/icons/folder.svg".asIconSource()
override val share get() = "icons/share.svg".asIconSource()
override val file get() = "icons/file.svg".asIconSource()
override val folder get() = "icons/folder.svg".asIconSource()
override val share get() = "/icons/share.svg".asIconSource()
override val file get() = "/icons/file.svg".asIconSource()
override val folder get() = "/icons/folder.svg".asIconSource()
override val fileOpen get() = file
override val folderOpen get() = folder
override val pictureFile get() = "icons/file_picture.svg".asIconSource()
override val musicFile get() = "icons/file_music.svg".asIconSource()
override val zipFile get() = "icons/file_zip.svg".asIconSource()
override val videoFile get() = "icons/file_video.svg".asIconSource()
override val applicationFile get() = "icons/file_application.svg".asIconSource()
override val documentFile get() = "icons/file_document.svg".asIconSource()
override val otherFile get() = "icons/file_unknown.svg".asIconSource()
override val pictureFile get() = "/icons/file_picture.svg".asIconSource()
override val musicFile get() = "/icons/file_music.svg".asIconSource()
override val zipFile get() = "/icons/file_zip.svg".asIconSource()
override val videoFile get() = "/icons/file_video.svg".asIconSource()
override val applicationFile get() = "/icons/file_application.svg".asIconSource()
override val documentFile get() = "/icons/file_document.svg".asIconSource()
override val otherFile get() = "/icons/file_unknown.svg".asIconSource()
override val lock get() = "icons/lock.svg".asIconSource()
override val lock get() = "/icons/lock.svg".asIconSource()
override val question get() = "icons/question_mark.svg".asIconSource()
override val question get() = "/icons/question_mark.svg".asIconSource()
override val sortUp get() = "icons/sort_321.svg".asIconSource()
override val sortDown get() = "icons/sort_123.svg".asIconSource()
override val verticalDirection get() = "icons/vertical_direction.svg".asIconSource()
override val sortUp get() = "/icons/sort_321.svg".asIconSource()
override val sortDown get() = "/icons/sort_123.svg".asIconSource()
override val verticalDirection get() = "/icons/vertical_direction.svg".asIconSource()
override val browserIntegration: IconSource get() = "icons/earth.svg".asIconSource()
override val appearance: IconSource get() = "icons/color.svg".asIconSource()
override val downloadEngine: IconSource get() = "icons/down_speed.svg".asIconSource()
override val network: IconSource get() = "icons/network.svg".asIconSource()
override val browserIntegration: IconSource get() = "/icons/earth.svg".asIconSource()
override val appearance: IconSource get() = "/icons/color.svg".asIconSource()
override val downloadEngine: IconSource get() = "/icons/down_speed.svg".asIconSource()
override val network: IconSource get() = "/icons/network.svg".asIconSource()
override val externalLink: IconSource get() = "icons/external_link.svg".asIconSource()
override val externalLink: IconSource get() = "/icons/external_link.svg".asIconSource()
}

View File

@ -3,14 +3,16 @@ package com.abdownloadmanager.desktop.ui.widget
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun ExpandableItem(
isExpanded:Boolean,
header:@Composable ()->Unit,
body:@Composable ()->Unit
body: @Composable () -> Unit,
modifier: Modifier = Modifier,
){
Column {
Column(modifier) {
header()
AnimatedVisibility(isExpanded){
body()

View File

@ -0,0 +1,82 @@
package com.abdownloadmanager.desktop.ui.widget
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
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.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.rememberComponentRectPositionProvider
import com.abdownloadmanager.desktop.pages.settings.configurable.Configurable
import com.abdownloadmanager.desktop.ui.icon.MyIcons
import com.abdownloadmanager.desktop.ui.theme.myColors
import com.abdownloadmanager.desktop.ui.theme.myTextSizes
import com.abdownloadmanager.utils.compose.WithContentColor
import com.abdownloadmanager.utils.compose.widget.MyIcon
@Composable
fun Help(
content: String,
) {
var showHelpContent by remember { mutableStateOf(false) }
val onRequestCloseShowHelpContent = {
showHelpContent = false
}
Column {
MyIcon(
MyIcons.question,
"Hint",
Modifier
.clip(CircleShape)
.clickable {
showHelpContent = !showHelpContent
}
.border(
1.dp,
if (showHelpContent) myColors.primary
else Color.Transparent,
CircleShape
)
.background(myColors.surface)
.padding(4.dp)
.size(12.dp),
tint = myColors.onSurface,
)
if (showHelpContent) {
Popup(
popupPositionProvider = rememberComponentRectPositionProvider(
anchor = Alignment.TopCenter,
alignment = Alignment.TopCenter,
),
onDismissRequest = onRequestCloseShowHelpContent
) {
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) {
Text(
content,
fontSize = myTextSizes.base,
)
}
}
}
}
}
}

View File

@ -0,0 +1,151 @@
package com.abdownloadmanager.desktop.ui.widget
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import com.abdownloadmanager.desktop.ui.theme.myColors
import com.abdownloadmanager.desktop.ui.util.ifThen
import com.abdownloadmanager.desktop.ui.widget.menu.MyDropDown
import com.abdownloadmanager.desktop.utils.div
import com.abdownloadmanager.utils.compose.widget.MyIcon
import ir.amirab.util.compose.IconSource
@Composable
fun IconPick(
selectedIcon: IconSource?,
icons: List<IconSource>,
onSelected: (IconSource) -> Unit,
onCancel: () -> Unit,
) {
MyDropDown(
onDismissRequest = onCancel,
offset = DpOffset(y = 2.dp, x = 0.dp),
content = {
val shape = RoundedCornerShape(6.dp)
Box(
Modifier
.shadow(24.dp)
// .verticalScroll(rememberScrollState())
.clip(shape)
// .width(IntrinsicSize.Max)
.widthIn(120.dp)
.height(220.dp)
.border(1.dp, myColors.surface, shape)
.background(myColors.menuGradientBackground)
) {
Content(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 0.dp),
selectedIcon = selectedIcon,
icons = icons,
onSelected = onSelected,
)
}
}
)
}
@Composable
private fun Content(
modifier: Modifier,
selectedIcon: IconSource?,
icons: List<IconSource>,
onSelected: (IconSource) -> Unit,
) {
val state = rememberLazyListState()
Box {
LazyColumn(
modifier = modifier,
state = state,
contentPadding = PaddingValues(vertical = 8.dp),
content = {
val shape = RoundedCornerShape(6.dp)
items(icons.chunked(6)) { rowItems ->
Row {
for (iconSource in rowItems) {
val isSelected = selectedIcon == iconSource
MyIcon(
iconSource,
null,
Modifier
.clip(shape)
.ifThen(isSelected) {
background(myColors.primary / 0.25f)
}
.border(
1.dp,
if (isSelected) myColors.primary / 0.25f
else Color.Transparent,
shape
)
.clickable {
onSelected(iconSource)
}
.padding(8.dp)
.size(24.dp),
)
}
}
}
// LazyVerticalGrid(
// columns = GridCells.Fixed(6),
// content = {
// val shape = RoundedCornerShape(6.dp)
// items(icons) {
// MyIcon(
// it,
// null,
// Modifier
// .clip(shape)
// .ifThen(selectedIcon == it) {
// background(myColors.primary / 0.25f)
// }
// .clickable {
// onSelected(it)
// }
// .padding(8.dp)
// .size(24.dp),
// )
// }
// }
// )
}
)
AnimatedVisibility(
state.canScrollForward,
modifier = Modifier.matchParentSize(),
enter = fadeIn(),
exit = fadeOut(),
) {
Spacer(
Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colorStops = arrayOf(
0f to Color.Transparent,
0.8f to Color.Transparent,
1f to myColors.background,
)
)
)
)
}
}
}

View File

@ -57,7 +57,9 @@ fun MyTextField(
enabled: Boolean = true,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
singleLine: Boolean = true,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
minLines: Int = 1,
start: @Composable (RowScope.() -> Unit)? = null,
end: @Composable (RowScope.() -> Unit)? = null,
) {
@ -98,7 +100,9 @@ fun MyTextField(
BasicTextField(
value = text,
singleLine = true,
singleLine = singleLine,
maxLines = maxLines,
minLines = minLines,
onValueChange = onTextChange,
interactionSource = interactionSource,
enabled = enabled,

View File

@ -1,5 +1,7 @@
package com.abdownloadmanager.desktop.utils
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
@ -26,6 +28,7 @@ import java.io.File
class DownloadSystem(
val downloadManager: DownloadManager,
val queueManager: QueueManager,
val categoryManager: CategoryManager,
val downloadMonitor: IDownloadMonitor,
private val scope: CoroutineScope,
private val downloadListDB: IDownloadListDb,
@ -40,6 +43,7 @@ class DownloadSystem(
foldersRegistry.boot()
queueManager.boot()
downloadManager.boot()
categoryManager.boot()
booted.update { true }
}
@ -47,33 +51,62 @@ class DownloadSystem(
newItemsToAdd: List<DownloadItem>,
onDuplicateStrategy: (DownloadItem) -> OnDuplicateStrategy,
queueId: Long? = null,
categorySelectionMode: CategorySelectionMode? = null,
): List<Long> {
return newItemsToAdd.map {
val createdIds = newItemsToAdd.map {
downloadManager.addDownload(it, onDuplicateStrategy(it))
}.also { ids ->
}
createdIds.also { ids ->
queueId?.let {
queueManager.addToQueue(
it, ids
)
}
}
categorySelectionMode?.let {
when (it) {
CategorySelectionMode.Auto -> {
categoryManager.autoAddItemsToCategoriesBasedOnFileNames(
createdIds.mapIndexed { index: Int, id: Long ->
id to newItemsToAdd.get(index).name
}
)
}
is CategorySelectionMode.Fixed -> {
categoryManager.addItemsToCategory(
it.categoryId,
createdIds,
)
}
}
}
return createdIds
}
suspend fun addDownload(
downloadItem: DownloadItem,
onDuplicateStrategy: OnDuplicateStrategy,
queueId: Long?,
categoryId: Long?,
context: DownloadItemContext = EmptyContext,
): Long {
val downloadId = downloadManager.addDownload(downloadItem, onDuplicateStrategy, context)
queueId?.let {
queueManager.addToQueue(queueId, downloadId)
}
categoryId?.let {
categoryManager.addItemsToCategory(
categoryId = categoryId,
itemIds = listOf(downloadId)
)
}
return downloadId
}
suspend fun removeDownload(id: Long, alsoRemoveFile: Boolean) {
downloadManager.deleteDownload(id, alsoRemoveFile,RemovedBy(User))
categoryManager.removeItemInCategories(listOf(id))
}
suspend fun manualResume(id: Long): Boolean {
@ -144,7 +177,12 @@ class DownloadSystem(
val id = items.sortedByDescending { it.dateAdded }.first().id
return id
}
val id = addDownload(downloadItem, OnDuplicateStrategy.AddNumbered, null)
val id = addDownload(
downloadItem = downloadItem,
onDuplicateStrategy = OnDuplicateStrategy.AddNumbered,
queueId = null,
categoryId = null,
)
return id
}

View File

@ -1,9 +1,11 @@
plugins {
id(MyPlugins.kotlin)
id(MyPlugins.composeBase)
id(Plugins.Kotlin.serialization)
}
dependencies {
implementation(project(":downloader:core"))
implementation(project(":downloader:monitor"))
api(project(":shared:config"))
api(project(":shared:utils"))
api(project(":shared:compose-utils"))

View File

@ -0,0 +1,62 @@
package com.abdownloadmanager.utils
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import com.abdownloadmanager.utils.category.CategoryManager
import com.abdownloadmanager.utils.category.DefaultCategories
import com.abdownloadmanager.utils.category.iconSource
import com.abdownloadmanager.utils.compose.IMyIcons
import ir.amirab.util.compose.IconSource
interface FileIconProvider {
fun getIcon(fileName: String): IconSource
/**
* Automatically update icon if other dependencies changed
*/
@Composable
fun rememberIcon(fileName: String): IconSource
}
class FileIconProviderUsingCategoryIcons(
private val defaultCategories: DefaultCategories,
private val categoryManager: CategoryManager,
private val icons: IMyIcons,
) : FileIconProvider {
override fun getIcon(fileName: String): IconSource {
return fromDefaultCategories(fileName)
?: fromUserDefinedCategories(fileName)
?: icons.file
}
@Composable
override fun rememberIcon(fileName: String): IconSource {
val fromDefault = remember(fileName) {
fromDefaultCategories(fileName)
}
if (fromDefault != null) {
return fromDefault
}
val categories by categoryManager.categoriesFlow.collectAsState()
val fromCategories = remember(fileName, categories) {
fromUserDefinedCategories(fileName)
}
if (fromCategories != null) {
return fromCategories
}
return icons.file
}
private fun fromDefaultCategories(fileName: String): IconSource? {
return defaultCategories
.getCategoryOfFileName(fileName)?.iconSource()
}
private fun fromUserDefinedCategories(fileName: String): IconSource? {
return categoryManager
.getCategoryOfFileName(fileName)?.iconSource()
}
}

View File

@ -0,0 +1,51 @@
package com.abdownloadmanager.utils.category
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import ir.amirab.util.compose.IconSource
import ir.amirab.util.compose.fromUri
import kotlinx.serialization.Serializable
/**
* @param path
* this is a default download path for this category
* @param icon
* can be used by [IconSource]
*/
@Immutable
@Serializable
data class Category(
val id: Long,
val name: String,
val path: String,
val acceptedFileTypes: List<String>,
val icon: String,
val items: List<Long>,
) {
fun acceptFileName(fileName: String): Boolean {
return acceptedFileTypes.any { ext ->
fileName.endsWith(
suffix = ".$ext",
ignoreCase = true
)
}
}
fun withExtraItems(newItems: List<Long>): Category {
return copy(
items = items.plus(newItems).distinct()
)
}
}
fun Category.iconSource(): IconSource? {
return IconSource.fromUri(icon)
}
@Composable
fun Category.rememberIconPainter(): IconSource? {
return remember(icon) {
iconSource()
}
}

View File

@ -0,0 +1,26 @@
package com.abdownloadmanager.utils.category
import ir.amirab.downloader.db.TransactionalFileSaver
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File
class CategoryFileStorage(
val file: File,
val fileSaver: TransactionalFileSaver,
) : CategoryStorage {
val lock = Mutex()
override suspend fun setCategories(categories: List<Category>) {
lock.withLock {
fileSaver.writeObject(file, categories)
}
}
override suspend fun getCategories(): List<Category> {
return fileSaver.readObject(file) ?: emptyList()
}
override suspend fun isCategoriesSet(): Boolean {
return file.exists()
}
}

View File

@ -0,0 +1,221 @@
package com.abdownloadmanager.utils.category
import ir.amirab.downloader.DownloadManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.withContext
import java.io.File
class CategoryManager(
private val categoryStorage: CategoryStorage,
private val scope: CoroutineScope,
private val defaultCategoriesFactory: DefaultCategories,
private val downloadManager: DownloadManager,
) {
private val _categories = MutableStateFlow<List<Category>>(emptyList())
val categoriesFlow = _categories.asStateFlow()
private var booted = false
@OptIn(FlowPreview::class)
suspend fun boot() {
synchronized(this) {
if (booted) return
}
if (categoryStorage.isCategoriesSet()) {
_categories.value = categoryStorage
.getCategories()
} else {
reset()
}
_categories
.sample(500)
.onEach { categoryStorage.setCategories(it) }
.launchIn(scope)
booted = true
}
suspend fun reset() {
val newCategories = defaultCategoriesFactory.getDefaultCategories()
setCategories(newCategories)
withContext(Dispatchers.IO) {
newCategories.forEach {
prepareCategory(it)
}
autoAddItemsToCategoriesBasedOnFileNames(
downloadManager
.getDownloadList()
.map { it.id to it.name }
)
}
}
fun getCategories(): List<Category> {
return _categories.value
}
fun setCategories(categories: List<Category>) {
_categories.update { categories }
}
fun getCategoryById(id: Long): Category? {
return getCategories()
.firstOrNull { it.id == id }
}
fun getCategoryOfType(extension: String): Category? {
return getCategories().firstOrNull { c ->
c.acceptedFileTypes.any {
it.equals(extension, true)
}
}
}
fun getCategoryOfFileName(fileName: String): Category? {
return getCategories()
.firstOrNull {
it.acceptFileName(fileName)
}
}
fun getCategoryOfItem(id: Long): Category? {
return getCategories()
.firstOrNull {
it.items.contains(id)
}
}
fun deleteCategory(category: Category) {
deleteCategory(category.id)
}
fun deleteCategory(categoryId: Long) {
_categories.update {
it.filter {
it.id != categoryId
}
}
}
fun addCustomCategory(category: Category) {
require(category.id == -1L)
val categories = getCategories()
val newId = (
categories
.maxOfOrNull { it.id }
?.coerceAtLeast(DEFAULT_CATEGORY_END_ID)
?: DEFAULT_CATEGORY_END_ID
) + 1
val newCategory = category.copy(
id = newId
)
setCategories(
categories.plus(
newCategory
)
)
prepareCategory(newCategory)
}
fun createDirectoryIfNecessary(category: Category) {
kotlin.runCatching {
val folder = File(category.path)
if (folder.exists()) {
folder.mkdirs()
}
}
}
private fun prepareCategory(newCategory: Category) {
createDirectoryIfNecessary(newCategory)
}
fun updateCategory(categoryToUpdate: Category) {
_categories.update {
it.updatedItem(
categoryId = categoryToUpdate.id,
update = { categoryToUpdate }
)
}
}
fun updateCategory(id: Long, categoryToUpdate: (Category) -> Category) {
_categories.update {
it.updatedItem(id, categoryToUpdate)
}
}
fun addItemsToCategory(categoryId: Long, itemIds: List<Long>) {
_categories.update { previousCategories ->
previousCategories
.removedItemIds(itemIds)
.updatedItem(categoryId) {
it.withExtraItems(itemIds)
}
}
}
fun removeItemInCategories(idsToRemove: List<Long>) {
_categories.update {
it.removedItemIds(idsToRemove)
}
}
fun isDefaultCategory(category: Category): Boolean {
return category.id in 0..DEFAULT_CATEGORY_END_ID
}
fun autoAddItemsToCategoriesBasedOnFileNames(
unCategorizedItems: List<Pair<Long, String>>,
) {
val newItemsMap = mutableMapOf<Long, MutableList<Long>>()
var count = 0
for ((id, name) in unCategorizedItems) {
val categoryToUpdate = getCategoryOfFileName(name) ?: continue
newItemsMap
.getOrPut(categoryToUpdate.id) { mutableListOf() }
.add(id)
count++
}
for ((categoryId, itemsToAdd) in newItemsMap) {
updateCategory(categoryId) {
it.withExtraItems(itemsToAdd)
}
}
}
fun isThisPathBelongsToACategory(folder: String): Boolean {
return getCategories()
.map { it.path }.contains(folder)
}
companion object {
/**
* Reserved ids for default categories
* this is too big BTW as we only use 5 for now
* maybe we need more or extra hidden categories that users can enable (maybe ?)
*/
const val DEFAULT_CATEGORY_END_ID = 100L
}
}
private fun List<Category>.removedItemIds(itemIds: List<Long>): List<Category> {
return map {
it.copy(
items = it.items.filter { itemId ->
itemId !in itemIds
}
)
}
}
private inline fun List<Category>.updatedItem(categoryId: Long, update: (Category) -> Category): List<Category> {
return map {
if (it.id == categoryId) {
update(it)
} else it
}
}

View File

@ -0,0 +1,18 @@
package com.abdownloadmanager.utils.category
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@Composable
fun CategoryManager.rememberCategoryOf(
itemId: Long,
): Category? {
val categories by categoriesFlow.collectAsState()
return remember(itemId, categories) {
categories.firstOrNull {
it.items.contains(itemId)
}
}
}

View File

@ -0,0 +1,9 @@
package com.abdownloadmanager.utils.category
import androidx.compose.runtime.Immutable
@Immutable
sealed interface CategorySelectionMode {
data class Fixed(val categoryId: Long) : CategorySelectionMode
data object Auto : CategorySelectionMode
}

View File

@ -0,0 +1,7 @@
package com.abdownloadmanager.utils.category
interface CategoryStorage {
suspend fun setCategories(categories: List<Category>)
suspend fun getCategories(): List<Category>
suspend fun isCategoriesSet(): Boolean
}

View File

@ -0,0 +1,165 @@
package com.abdownloadmanager.utils.category
import com.abdownloadmanager.utils.compose.IMyIcons
import ir.amirab.util.compose.IconSource
import ir.amirab.util.compose.uriOrNull
import java.io.File
class DefaultCategories(
private val icons: IMyIcons,
private val getDefaultDownloadFolder: () -> String,
) {
fun getCategoryOfFileName(name: String): Category? {
return getDefaultCategories()
.firstOrNull { it.acceptFileName(name) }
}
fun getDefaultCategories(): List<Category> {
fun IconSource.toUri(): String {
return requireNotNull(uriOrNull()) {
"It seems that we use an icon that does not have uri"
}
}
fun relative(path: String): String {
return File(getDefaultDownloadFolder(), path).path
}
val compressed = Category(
id = 0,
name = "Compressed",
path = relative("Compressed"),
icon = icons.zipFile.toUri(),
acceptedFileTypes = listOf(
"zip",
"rar",
"7z",
"tar",
"gz",
"bz2",
"xz",
"iso",
"dmg",
"tgz",
),
items = emptyList(),
)
val programs = Category(
id = 1,
name = "Programs",
path = relative("Programs"),
icon = icons.applicationFile.toUri(),
acceptedFileTypes = listOf(
"apk",
"exe",
"msi",
"bat",
"sh",
"jar",
"app",
"deb",
"rpm",
"bin",
),
items = emptyList(),
)
val videos = Category(
id = 2,
name = "Videos",
path = relative("Videos"),
icon = icons.videoFile.toUri(),
acceptedFileTypes = listOf(
"mp4",
"avi",
"mkv",
"mov",
"wmv",
"flv",
"webm",
"m4v",
"3gp",
"mpeg",
),
items = emptyList(),
)
val music = Category(
id = 3,
name = "Music",
path = relative("Music"),
icon = icons.musicFile.toUri(),
acceptedFileTypes = listOf(
"mp3",
"wav",
"aac",
"flac",
"ogg",
"aiff",
"wma",
"m4a",
),
items = emptyList(),
)
val pictures = Category(
id = 4,
name = "Pictures",
path = relative("Pictures"),
icon = icons.pictureFile.toUri(),
acceptedFileTypes = listOf(
"jpg",
"jpeg",
"png",
"gif",
"bmp",
"tiff",
"tif",
"svg",
"webp",
"heic",
"ico",
"raw",
"psd",
),
items = emptyList(),
)
val documents = Category(
id = 5,
name = "Documents",
path = relative("Documents"),
icon = icons.documentFile.toUri(),
acceptedFileTypes = listOf(
"doc",
"docx",
"pdf",
"txt",
"rtf",
"odt",
"xls",
"xlsx",
"ppt",
"pptx",
"csv",
"epub",
"pages",
),
items = emptyList(),
)
return listOf(
compressed,
programs,
videos,
music,
pictures,
documents,
)
}
fun isDefault(categories: List<Category>): Boolean {
return getDefaultCategories() == categories.map {
it.copy(items = emptyList())
}
}
}

View File

@ -0,0 +1,17 @@
package com.abdownloadmanager.utils.category
class InMemoryCategoryStorage : CategoryStorage {
private var categories = emptyList<Category>()
override suspend fun setCategories(categories: List<Category>) {
this.categories = categories
}
override suspend fun getCategories(): List<Category> {
return categories
}
override suspend fun isCategoriesSet(): Boolean {
return true
}
}

View File

@ -7,7 +7,7 @@ context (IMyIcons)
fun ImageVector.asIconSource(requiredTint: Boolean = true) = IconSource.VectorIconSource(this, requiredTint)
context (IMyIcons)
fun String.asIconSource(requiredTint: Boolean = true) = IconSource.StorageIconSource(this, requiredTint)
fun String.asIconSource(requiredTint: Boolean = true) = IconSource.ResourceIconSource(this, requiredTint)
interface IMyIcons {
val appIcon: IconSource
@ -71,7 +71,7 @@ interface IMyIcons {
val question: IconSource
val sortUp: IconSource
val sortDown: IconSource
val verticalDirection: IconSource.StorageIconSource
val verticalDirection: IconSource
val appearance: IconSource
val downloadEngine: IconSource
val browserIntegration: IconSource

View File

@ -5,7 +5,13 @@ import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.res.ClassLoaderResourceLoader
import androidx.compose.ui.res.painterResource
import okio.FileSystem
import okio.Path.Companion.toPath
import java.net.URI
private const val RESOURCE_PROTOCOL = "app-resource"
@Immutable
sealed interface IconSource {
@ -16,12 +22,16 @@ sealed interface IconSource {
fun rememberPainter(): Painter
@Immutable
data class StorageIconSource(
data class ResourceIconSource(
override val value: String,
override val requiredTint: Boolean,
) : IconSource {
) : IconSourceWithURI {
@Composable
override fun rememberPainter(): Painter = painterResource(value)
override fun toUri() = "$RESOURCE_PROTOCOL:$value?tint=${requiredTint}"
override fun exists(): Boolean {
return FileSystem.RESOURCES.exists(value.toPath())
}
}
@Immutable
@ -32,4 +42,30 @@ sealed interface IconSource {
@Composable
override fun rememberPainter(): Painter = rememberVectorPainter(value)
}
companion object
}
interface IconSourceWithURI : IconSource {
fun toUri(): String
fun exists(): Boolean
}
fun IconSource.uriOrNull() = (this as? IconSourceWithURI)?.toUri()
@Suppress("NAME_SHADOWING")
fun IconSource.Companion.fromUri(uri: String): IconSourceWithURI? {
val uri = URI(uri)
return when (uri.scheme) {
RESOURCE_PROTOCOL -> IconSource.ResourceIconSource(
value = uri.path,
// requiredTint = uri.query["tint"]?.toBooleanOrNull()?:true,
requiredTint = true,
)
else -> null
// else -> kotlin.runCatching { uri.toURL() }
// .getOrNull()
// ?.openStream()
}?.takeIf { it.exists() }
}