From 4622b914098cccb1aa0c7e20d3733d8e7b9d42c3 Mon Sep 17 00:00:00 2001 From: AmirHossein Abdolmotallebi Date: Sat, 12 Oct 2024 00:24:35 +0330 Subject: [PATCH] add custom categories --- .../abdownloadmanager/desktop/AppComponent.kt | 124 ++++- .../abdownloadmanager/desktop/actions/main.kt | 16 + .../com/abdownloadmanager/desktop/di/Di.kt | 49 +- .../addDownload/ShowAddDownloadDialogs.kt | 4 +- .../multiple/AddMultiDownloadComponent.kt | 106 +++- .../addDownload/multiple/AddMultiItemPage.kt | 455 ++++++++++++++++-- .../addDownload/shared/CategorySelect.kt | 121 +++++ .../addDownload/shared/DialogDropDown.kt | 230 +++++++++ .../addDownload/single/AddDownloadPage.kt | 69 ++- .../single/AddSingleDownloadComponent.kt | 143 +++++- .../pages/category/CategoryComponent.kt | 108 +++++ .../pages/category/CategoryDialogManager.kt | 9 + .../desktop/pages/category/NewCategoryPage.kt | 381 +++++++++++++++ .../pages/category/ShowCategoryDialogs.kt | 34 ++ .../desktop/pages/home/HomeComponent.kt | 244 +++++++++- .../desktop/pages/home/HomePage.kt | 231 ++++++++- .../pages/home/sections/DownloadList.kt | 11 +- .../pages/home/sections/TableDownloadItem.kt | 38 +- .../home/sections/category/Categories.kt | 110 ++--- .../com/abdownloadmanager/desktop/ui/Ui.kt | 2 + .../desktop/ui/icon/MyIcons.kt | 116 ++--- .../desktop/ui/widget/ExpandableItem.kt | 6 +- .../desktop/ui/widget/Help.kt | 82 ++++ .../desktop/ui/widget/IconPick.kt | 151 ++++++ .../desktop/ui/widget/MyTextField.kt | 8 +- .../desktop/utils/DownloadSystem.kt | 44 +- shared/app-utils/build.gradle.kts | 2 + .../utils/FileIconProvider.kt | 62 +++ .../utils/category/Category.kt | 51 ++ .../utils/category/CategoryFileStorage.kt | 26 + .../utils/category/CategoryManager.kt | 221 +++++++++ .../category/CategoryManagerExtensions.kt | 18 + .../utils/category/CategorySelectionMode.kt | 9 + .../utils/category/CategoryStorage.kt | 7 + .../utils/category/DefaultCategories.kt | 165 +++++++ .../utils/category/InMemoryCategoryStorage.kt | 17 + .../utils/compose/IMyIcons.kt | 4 +- .../ir/amirab/util/compose/IconSource.kt | 40 +- 38 files changed, 3221 insertions(+), 293 deletions(-) create mode 100644 desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/shared/CategorySelect.kt create mode 100644 desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/shared/DialogDropDown.kt create mode 100644 desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/category/CategoryComponent.kt create mode 100644 desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/category/CategoryDialogManager.kt create mode 100644 desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/category/NewCategoryPage.kt create mode 100644 desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/category/ShowCategoryDialogs.kt create mode 100644 desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/widget/Help.kt create mode 100644 desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/widget/IconPick.kt create mode 100644 shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/FileIconProvider.kt create mode 100644 shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/Category.kt create mode 100644 shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/CategoryFileStorage.kt create mode 100644 shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/CategoryManager.kt create mode 100644 shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/CategoryManagerExtensions.kt create mode 100644 shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/CategorySelectionMode.kt create mode 100644 shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/CategoryStorage.kt create mode 100644 shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/DefaultCategories.kt create mode 100644 shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/InMemoryCategoryStorage.kt diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/AppComponent.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/AppComponent.kt index c969fcd..3a2d76d 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/AppComponent.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/AppComponent.kt @@ -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 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() + 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> = _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() @@ -518,6 +612,7 @@ class AppComponent( fun addDownload( items: List, 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) diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/actions/main.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/actions/main.kt index 7c8cde8..257eada 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/actions/main.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/actions/main.kt @@ -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, +): AnAction { + return simpleAction(category.name) { + scope.launch { + downloadSystem + .categoryManager + .addItemsToCategory( + categoryId = category.id, + itemIds = itemIds, + ) + } + } +} fun stopQueueAction( queue: DownloadQueue, diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/di/Di.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/di/Di.kt index b5ca505..bc012a2 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/di/Di.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/di/Di.kt @@ -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 { DownloadMonitor(get()) } +} +val downloadSystemModule = module { + single { + CategoryFileStorage( + file = get().registerAndGet( + AppInfo.downloadDbDir.resolve("categories") + ).resolve("categories.json"), + fileSaver = get() + ) + }.bind() + single { + FileIconProviderUsingCategoryIcons( + get(), + get(), + MyIcons, + ) + }.bind() + single { + DefaultCategories( + icons = get(), + getDefaultDownloadFolder = { + get().defaultDownloadFolder.value + } + ) + } single { - DownloadSystem(get(), get(), get(), get(), get(), get()) + 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() single { AppSettingsStorage( createMyConfigPreferences( diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/ShowAddDownloadDialogs.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/ShowAddDownloadDialogs.kt index 9e0f4d5..70cf05a 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/ShowAddDownloadDialogs.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/ShowAddDownloadDialogs.kt @@ -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, diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/multiple/AddMultiDownloadComponent.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/multiple/AddMultiDownloadComponent.kt index f5672c2..0fd9892 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/multiple/AddMultiDownloadComponent.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/multiple/AddMultiDownloadComponent.kt @@ -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() private val client by inject() val downloadSystem by inject() - 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 by mutableStateOf(emptyList()) + private val _saveMode = MutableStateFlow(EachFileInTheirOwnCategory) + val saveMode = _saveMode.asStateFlow() + fun setSaveMode(saveMode: AddMultiItemSaveMode) { + _saveMode.update { saveMode } + } + private val checkList = MutableSharedFlow() private fun enqueueCheck(links: List) { @@ -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, onDuplicateStrategy: (DownloadItem) -> OnDuplicateStrategy, - queueId: Long? + queueId: Long?, + categorySelectionMode: CategorySelectionMode?, ) } \ No newline at end of file diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/multiple/AddMultiItemPage.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/multiple/AddMultiItemPage.kt index f7a0744..cb669f3 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/multiple/AddMultiItemPage.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/multiple/AddMultiItemPage.kt @@ -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 + 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)) - ActionButton( - text = "Cancel", - onClick = { - component.requestClose() - }, - modifier = Modifier, + 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, +) { + 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", + ); +} \ No newline at end of file diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/shared/CategorySelect.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/shared/CategorySelect.kt new file mode 100644 index 0000000..1f10868 --- /dev/null +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/shared/CategorySelect.kt @@ -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, + 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) + ) + } +} \ No newline at end of file diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/shared/DialogDropDown.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/shared/DialogDropDown.kt new file mode 100644 index 0000000..2e088de --- /dev/null +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/shared/DialogDropDown.kt @@ -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 DialogDropDown( + selectedItem: T?, + possibleItems: List, + 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 DropDownContent( + closeDialog: () -> Unit, + dropDownSize: DpSize, + possibleItems: List, + 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 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), + ) + } +} diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/single/AddDownloadPage.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/single/AddDownloadPage.kt index 80849a0..7469ef9 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/single/AddDownloadPage.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/single/AddDownloadPage.kt @@ -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, diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/single/AddSingleDownloadComponent.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/single/AddSingleDownloadComponent.kt index 73da3e1..c7b87ff 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/single/AddSingleDownloadComponent.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/single/AddSingleDownloadComponent.kt @@ -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 = 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?, + ) } \ No newline at end of file diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/category/CategoryComponent.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/category/CategoryComponent.kt new file mode 100644 index 0000000..9d99294 --- /dev/null +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/category/CategoryComponent.kt @@ -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! + ) + ) + } +} \ No newline at end of file diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/category/CategoryDialogManager.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/category/CategoryDialogManager.kt new file mode 100644 index 0000000..cd6f735 --- /dev/null +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/category/CategoryDialogManager.kt @@ -0,0 +1,9 @@ +package com.abdownloadmanager.desktop.pages.category + +import kotlinx.coroutines.flow.StateFlow + +interface CategoryDialogManager { + val openedCategoryDialogs: StateFlow> + fun openCategoryDialog(categoryId: Long) + fun closeCategoryDialog(categoryId: Long) +} \ No newline at end of file diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/category/NewCategoryPage.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/category/NewCategoryPage.kt new file mode 100644 index 0000000..da71a66 --- /dev/null +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/category/NewCategoryPage.kt @@ -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, + ) + } + } + } +} + diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/category/ShowCategoryDialogs.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/category/ShowCategoryDialogs.kt new file mode 100644 index 0000000..88bf477 --- /dev/null +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/category/ShowCategoryDialogs.kt @@ -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) +} \ No newline at end of file diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/HomeComponent.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/HomeComponent.kt index 59a6d87..fe70027 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/HomeComponent.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/HomeComponent.kt @@ -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(DefinedStatusCategories.All) } @@ -53,6 +58,13 @@ sealed interface HomeEffects { data class DeleteItems( val list: List, ) : 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>, private val mainItem: StateFlow, private val queueManager: QueueManager, + private val categoryManager: CategoryManager, private val openFile: (Long) -> Unit, private val openFolder: (Long) -> Unit, private val requestDelete: (List) -> 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 = 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 = 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 = listOf(DownloadCredentials.empty()), @@ -370,7 +540,6 @@ class HomeComponent( }.filterIsInstance() - 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 diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/HomePage.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/HomePage.kt index b3a2747..e9eb63b 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/HomePage.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/HomePage.kt @@ -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, @@ -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 diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/sections/DownloadList.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/sections/DownloadList.kt index 1c1d612..197f43c 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/sections/DownloadList.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/sections/DownloadList.kt @@ -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) -> 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 -> { diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/sections/TableDownloadItem.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/sections/TableDownloadItem.kt index 35becd9..b6c9a43 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/sections/TableDownloadItem.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/sections/TableDownloadItem.kt @@ -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 { error("not provided download properties") } + compositionLocalOf { 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 diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/sections/category/Categories.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/sections/category/Categories.kt index f38c1d3..533723b 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/sections/category/Categories.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/sections/category/Categories.kt @@ -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, -) : 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, + categories: List, 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 = { diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/Ui.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/Ui.kt index 5d6a1c8..3ef075e 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/Ui.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/Ui.kt @@ -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) diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/icon/MyIcons.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/icon/MyIcons.kt index 8f0b4af..a2d5e26 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/icon/MyIcons.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/icon/MyIcons.kt @@ -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() } diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/widget/ExpandableItem.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/widget/ExpandableItem.kt index f5edb1f..d0b0770 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/widget/ExpandableItem.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/widget/ExpandableItem.kt @@ -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() diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/widget/Help.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/widget/Help.kt new file mode 100644 index 0000000..0e0bae2 --- /dev/null +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/widget/Help.kt @@ -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, + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/widget/IconPick.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/widget/IconPick.kt new file mode 100644 index 0000000..71736c3 --- /dev/null +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/widget/IconPick.kt @@ -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, + 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, + 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, + ) + ) + ) + ) + } + } +} diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/widget/MyTextField.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/widget/MyTextField.kt index 4aeb0b3..1b72e28 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/widget/MyTextField.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/widget/MyTextField.kt @@ -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, diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/DownloadSystem.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/DownloadSystem.kt index f9fa8bf..dd1a3f0 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/DownloadSystem.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/DownloadSystem.kt @@ -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, onDuplicateStrategy: (DownloadItem) -> OnDuplicateStrategy, queueId: Long? = null, + categorySelectionMode: CategorySelectionMode? = null, ): List { - 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 } diff --git a/shared/app-utils/build.gradle.kts b/shared/app-utils/build.gradle.kts index 83b68e6..d93295e 100644 --- a/shared/app-utils/build.gradle.kts +++ b/shared/app-utils/build.gradle.kts @@ -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")) diff --git a/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/FileIconProvider.kt b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/FileIconProvider.kt new file mode 100644 index 0000000..1dfdc81 --- /dev/null +++ b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/FileIconProvider.kt @@ -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() + } +} \ No newline at end of file diff --git a/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/Category.kt b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/Category.kt new file mode 100644 index 0000000..c679161 --- /dev/null +++ b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/Category.kt @@ -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, + val icon: String, + val items: List, +) { + fun acceptFileName(fileName: String): Boolean { + return acceptedFileTypes.any { ext -> + fileName.endsWith( + suffix = ".$ext", + ignoreCase = true + ) + } + } + + fun withExtraItems(newItems: List): 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() + } +} \ No newline at end of file diff --git a/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/CategoryFileStorage.kt b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/CategoryFileStorage.kt new file mode 100644 index 0000000..b6bdcb8 --- /dev/null +++ b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/CategoryFileStorage.kt @@ -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) { + lock.withLock { + fileSaver.writeObject(file, categories) + } + } + + override suspend fun getCategories(): List { + return fileSaver.readObject(file) ?: emptyList() + } + + override suspend fun isCategoriesSet(): Boolean { + return file.exists() + } +} \ No newline at end of file diff --git a/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/CategoryManager.kt b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/CategoryManager.kt new file mode 100644 index 0000000..3fedea5 --- /dev/null +++ b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/CategoryManager.kt @@ -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>(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 { + return _categories.value + } + + fun setCategories(categories: List) { + _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) { + _categories.update { previousCategories -> + previousCategories + .removedItemIds(itemIds) + .updatedItem(categoryId) { + it.withExtraItems(itemIds) + } + } + } + + fun removeItemInCategories(idsToRemove: List) { + _categories.update { + it.removedItemIds(idsToRemove) + } + } + + fun isDefaultCategory(category: Category): Boolean { + return category.id in 0..DEFAULT_CATEGORY_END_ID + } + + fun autoAddItemsToCategoriesBasedOnFileNames( + unCategorizedItems: List>, + ) { + val newItemsMap = mutableMapOf>() + 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.removedItemIds(itemIds: List): List { + return map { + it.copy( + items = it.items.filter { itemId -> + itemId !in itemIds + } + ) + } +} + +private inline fun List.updatedItem(categoryId: Long, update: (Category) -> Category): List { + return map { + if (it.id == categoryId) { + update(it) + } else it + } +} \ No newline at end of file diff --git a/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/CategoryManagerExtensions.kt b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/CategoryManagerExtensions.kt new file mode 100644 index 0000000..b7765ae --- /dev/null +++ b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/CategoryManagerExtensions.kt @@ -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) + } + } +} diff --git a/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/CategorySelectionMode.kt b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/CategorySelectionMode.kt new file mode 100644 index 0000000..761520f --- /dev/null +++ b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/CategorySelectionMode.kt @@ -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 +} \ No newline at end of file diff --git a/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/CategoryStorage.kt b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/CategoryStorage.kt new file mode 100644 index 0000000..d1a6873 --- /dev/null +++ b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/CategoryStorage.kt @@ -0,0 +1,7 @@ +package com.abdownloadmanager.utils.category + +interface CategoryStorage { + suspend fun setCategories(categories: List) + suspend fun getCategories(): List + suspend fun isCategoriesSet(): Boolean +} \ No newline at end of file diff --git a/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/DefaultCategories.kt b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/DefaultCategories.kt new file mode 100644 index 0000000..d894dad --- /dev/null +++ b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/DefaultCategories.kt @@ -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 { + 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): Boolean { + return getDefaultCategories() == categories.map { + it.copy(items = emptyList()) + } + } +} diff --git a/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/InMemoryCategoryStorage.kt b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/InMemoryCategoryStorage.kt new file mode 100644 index 0000000..58b916f --- /dev/null +++ b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/category/InMemoryCategoryStorage.kt @@ -0,0 +1,17 @@ +package com.abdownloadmanager.utils.category + +class InMemoryCategoryStorage : CategoryStorage { + private var categories = emptyList() + + override suspend fun setCategories(categories: List) { + this.categories = categories + } + + override suspend fun getCategories(): List { + return categories + } + + override suspend fun isCategoriesSet(): Boolean { + return true + } +} \ No newline at end of file diff --git a/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/compose/IMyIcons.kt b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/compose/IMyIcons.kt index 8c774ea..d4ad0e5 100644 --- a/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/compose/IMyIcons.kt +++ b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/compose/IMyIcons.kt @@ -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 diff --git a/shared/compose-utils/src/main/kotlin/ir/amirab/util/compose/IconSource.kt b/shared/compose-utils/src/main/kotlin/ir/amirab/util/compose/IconSource.kt index 32abe75..6ccbafa 100644 --- a/shared/compose-utils/src/main/kotlin/ir/amirab/util/compose/IconSource.kt +++ b/shared/compose-utils/src/main/kotlin/ir/amirab/util/compose/IconSource.kt @@ -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() } +} \ No newline at end of file