diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..587e2093 --- /dev/null +++ b/LICENSE @@ -0,0 +1,191 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2025 linroid + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index e9a4c346..3130890b 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,11 @@ [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![Android](https://img.shields.io/badge/Android-26+-3DDC84.svg?logo=android&logoColor=white)](https://developer.android.com) [![iOS](https://img.shields.io/badge/iOS-supported-000000.svg?logo=apple&logoColor=white)](https://developer.apple.com) -[![JVM](https://img.shields.io/badge/JVM-11+-DB380E.svg?logo=openjdk&logoColor=white)](https://openjdk.org) +[![Desktop](https://img.shields.io/badge/Desktop-JVM_11+-DB380E.svg?logo=openjdk&logoColor=white)](https://openjdk.org) +[![Web](https://img.shields.io/badge/Web-WasmJs-E4A125.svg?logo=webassembly&logoColor=white)](https://kotlinlang.org/docs/wasm-overview.html) [![Built with Claude Code](https://img.shields.io/badge/Built_with-Claude_Code-6b48ff.svg?logo=anthropic&logoColor=white)](https://claude.ai/claude-code) -A full-featured Kotlin Multiplatform download manager — run locally, remotely, or embedded in your app. Supports Android, JVM, iOS, and WebAssembly. +A full-featured Kotlin Multiplatform download manager — run locally, remotely, or embedded in your app. Supports Android, iOS, Desktop, and Web. - **Embed it** — Add downloads to your Android, iOS, or Desktop app with a simple API - **Run it as a daemon** — Self-hosted download server with REST API and real-time SSE events @@ -96,10 +97,10 @@ KDown is split into published SDK modules that you add as dependencies: | `library:api` | Public API interfaces and models (`KDownApi`, `DownloadTask`, `DownloadState`, etc.) | All | | `library:core` | In-process download engine -- embed downloads directly in your app | All | | `library:ktor` | Ktor-based `HttpEngine` implementation (required by `core`) | All | -| `library:sqlite` | SQLite-backed `TaskStore` for persistent resume | Android, iOS, JVM | +| `library:sqlite` | SQLite-backed `TaskStore` for persistent resume | Android, iOS, Desktop | | `library:kermit` | Optional [Kermit](https://github.com/touchlab/Kermit) logging integration | All | | `library:remote` | Remote client -- control a KDown daemon server from any platform | All | -| `server` | Daemon server with REST API and SSE events (not an SDK; standalone service) | JVM | +| `server` | Daemon server with REST API and SSE events (not an SDK; standalone service) | Desktop | Choose your backend: use **`core`** for in-process downloads, or **`remote`** to control a daemon server. Both implement the same `KDownApi` interface, so your UI code works identically. @@ -138,7 +139,7 @@ Ready-made `HttpEngine` backed by Ktor Client with per-platform engines: |---|---| | Android | OkHttp | | iOS | Darwin | -| JVM | CIO | +| Desktop | CIO | | WasmJs | Js | ## Configuration @@ -233,7 +234,7 @@ See [LOGGING.md](LOGGING.md) for detailed documentation. Run KDown as a background service and control it remotely: ```kotlin -// Server side (JVM) +// Server side (Desktop) val kdown = KDown(httpEngine = KtorHttpEngine()) val server = KDownServer(kdown) server.start() // REST API + SSE on port 8642 @@ -246,7 +247,7 @@ task.state.collect { /* real-time updates via SSE */ } ## Platform Support -| Feature | Android | JVM | iOS | WasmJs | +| Feature | Android | Desktop | iOS | WasmJs | |---|---|---|---|---| | Segmented downloads | Yes | Yes | Yes | Remote only* | | Pause / Resume | Yes | Yes | Yes | Remote only* | diff --git a/app/desktop/build.gradle.kts b/app/desktop/build.gradle.kts index c8f59bdd..276fd702 100644 --- a/app/desktop/build.gradle.kts +++ b/app/desktop/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.kotlinJvm) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) + alias(libs.plugins.composeHotReload) } dependencies { @@ -17,7 +18,7 @@ dependencies { compose.desktop { application { - mainClass = "com.linroid.kdown.app.MainKt" + mainClass = "com.linroid.kdown.desktop.MainKt" nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) diff --git a/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/App.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/App.kt index 641fc1c7..8e9d28a9 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/App.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/App.kt @@ -1,1347 +1,13 @@ package com.linroid.kdown.app -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Cloud -import androidx.compose.material.icons.filled.Computer -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Pause -import androidx.compose.material.icons.filled.PhoneAndroid -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.FilterChip -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Slider -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.linroid.kdown.api.DownloadPriority -import com.linroid.kdown.api.DownloadRequest -import com.linroid.kdown.api.DownloadState -import com.linroid.kdown.api.DownloadTask -import com.linroid.kdown.api.KDownApi -import com.linroid.kdown.api.SpeedLimit -import com.linroid.kdown.app.backend.BackendConfig -import com.linroid.kdown.app.backend.BackendEntry -import com.linroid.kdown.remote.ConnectionState import com.linroid.kdown.app.backend.BackendManager -import com.linroid.kdown.app.backend.ServerState -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch +import com.linroid.kdown.app.theme.KDownTheme +import com.linroid.kdown.app.ui.AppShell -@OptIn(ExperimentalMaterial3Api::class) @Composable fun App(backendManager: BackendManager) { - val activeApi by backendManager.activeApi.collectAsState() - val activeBackend by backendManager.activeBackend.collectAsState() - val backends by backendManager.backends.collectAsState() - val connectionState by ( - activeBackend?.connectionState - ?: MutableStateFlow(ConnectionState.Disconnected()) - ).collectAsState() - val scope = rememberCoroutineScope() - val tasks by activeApi.tasks.collectAsState() - val version by activeApi.version.collectAsState() - var showAddDialog by remember { mutableStateOf(false) } - var showBackendSelector by remember { mutableStateOf(false) } - var showAddRemoteDialog by remember { mutableStateOf(false) } - var errorMessage by remember { mutableStateOf(null) } - var switchingBackendId by remember { mutableStateOf(null) } - - DisposableEffect(Unit) { - onDispose { backendManager.close() } - } - - val sortedTasks = remember(tasks) { - tasks.sortedByDescending { it.createdAt } - } - - MaterialTheme { - Scaffold( - topBar = { - TopAppBar( - title = { - Column { - Text( - text = "KDown", - fontWeight = FontWeight.SemiBold - ) - Row( - modifier = Modifier.clickable { - showBackendSelector = true - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Text( - text = "v${version.backend} \u00b7 " + - (activeBackend?.label ?: "Not connected"), - style = MaterialTheme.typography.bodySmall, - color = - MaterialTheme.colorScheme.onSurfaceVariant - ) - ConnectionStatusDot(connectionState) - } - } - } - ) - }, - floatingActionButton = { - FloatingActionButton(onClick = { showAddDialog = true }) { - Icon(Icons.Filled.Add, contentDescription = "Add download") - } - } - ) { paddingValues -> - if (sortedTasks.isEmpty() && errorMessage == null) { - EmptyState( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - onAddClick = { showAddDialog = true } - ) - } else { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - if (errorMessage != null) { - item(key = "error-banner") { - Card( - colors = CardDefaults.cardColors( - containerColor = - MaterialTheme.colorScheme.errorContainer - ), - modifier = Modifier.fillMaxWidth() - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - text = errorMessage ?: "", - style = MaterialTheme.typography.bodySmall, - color = - MaterialTheme.colorScheme.onErrorContainer, - modifier = Modifier.weight(1f) - ) - TextButton( - onClick = { errorMessage = null } - ) { - Text("Dismiss") - } - } - } - } - } - items( - items = sortedTasks, - key = { it.taskId } - ) { task -> - DownloadTaskItem( - task = task, - scope = scope, - onPause = { scope.launch { task.pause() } }, - onResume = { scope.launch { task.resume() } }, - onCancel = { scope.launch { task.cancel() } }, - onRetry = { scope.launch { task.resume() } }, - onRemove = { scope.launch { task.remove() } } - ) - } - } - } - } - - if (showAddDialog) { - AddDownloadDialog( - onDismiss = { showAddDialog = false }, - onDownload = { url, fileName, speedLimit, priority -> - showAddDialog = false - errorMessage = null - startDownload( - scope = scope, - kdown = activeApi, - url = url, - directory = "downloads", - fileName = fileName.ifBlank { null }, - speedLimit = speedLimit, - priority = priority, - onError = { errorMessage = it } - ) - } - ) - } - - if (showBackendSelector) { - BackendSelectorSheet( - backendManager = backendManager, - activeBackendId = activeBackend?.id, - switchingBackendId = switchingBackendId, - onSelectBackend = { entry -> - if (entry.id != activeBackend?.id && - switchingBackendId == null - ) { - switchingBackendId = entry.id - scope.launch { - try { - backendManager.switchTo(entry.id) - showBackendSelector = false - } catch (e: Exception) { - errorMessage = - "Failed to switch backend: ${e.message}" - } finally { - switchingBackendId = null - } - } - } - }, - onRemoveBackend = { entry -> - scope.launch { - try { - backendManager.removeBackend(entry.id) - } catch (e: Exception) { - errorMessage = - "Failed to remove backend: ${e.message}" - } - } - }, - onAddRemoteServer = { - showAddRemoteDialog = true - }, - onDismiss = { showBackendSelector = false } - ) - } - - if (showAddRemoteDialog) { - AddRemoteServerDialog( - onDismiss = { showAddRemoteDialog = false }, - onAdd = { host, port, token -> - showAddRemoteDialog = false - try { - backendManager.addRemote(host, port, token) - } catch (e: Exception) { - errorMessage = - "Failed to add remote server: ${e.message}" - } - } - ) - } - - } -} - -@Composable -private fun EmptyState( - modifier: Modifier = Modifier, - onAddClick: () -> Unit -) { - Box( - modifier = modifier, - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = "No downloads yet", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = "Add a URL to start downloading", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(8.dp)) - Button(onClick = onAddClick) { - Text("Add download") - } - } - } -} - -private data class SpeedOption( - val label: String, - val limit: SpeedLimit -) - -private val speedOptions = listOf( - SpeedOption("Unlimited", SpeedLimit.Unlimited), - SpeedOption("1 MB/s", SpeedLimit.mbps(1)), - SpeedOption("5 MB/s", SpeedLimit.mbps(5)), - SpeedOption("10 MB/s", SpeedLimit.mbps(10)) -) - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun AddDownloadDialog( - onDismiss: () -> Unit, - onDownload: ( - url: String, - fileName: String, - SpeedLimit, - DownloadPriority - ) -> Unit -) { - var url by remember { mutableStateOf("") } - var fileName by remember { mutableStateOf("") } - var selectedSpeed by remember { mutableStateOf(SpeedLimit.Unlimited) } - var selectedPriority by remember { - mutableStateOf(DownloadPriority.NORMAL) - } - val isValidUrl = url.isBlank() || - url.trim().startsWith("http://") || - url.trim().startsWith("https://") - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Add download") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - OutlinedTextField( - value = url, - onValueChange = { - url = it - fileName = extractFilename(it) - }, - modifier = Modifier.fillMaxWidth(), - label = { Text("URL") }, - singleLine = true, - placeholder = { - Text("https://example.com/file.zip") - }, - isError = !isValidUrl, - supportingText = if (!isValidUrl) { - { Text("URL must start with http:// or https://") } - } else { - null - } - ) - OutlinedTextField( - value = fileName, - onValueChange = { fileName = it }, - modifier = Modifier.fillMaxWidth(), - label = { Text("Save as") }, - singleLine = true, - placeholder = { Text("Auto-detected from URL") }, - supportingText = if (fileName.isBlank() && - url.isNotBlank() - ) { - { Text("Will be extracted from URL") } - } else { - null - } - ) - Text( - text = "Priority", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - DownloadPriority.entries.forEach { priority -> - FilterChip( - selected = selectedPriority == priority, - onClick = { selectedPriority = priority }, - label = { Text(priorityLabel(priority)) } - ) - } - } - Text( - text = "Speed limit", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - speedOptions.forEach { option -> - FilterChip( - selected = selectedSpeed == option.limit, - onClick = { selectedSpeed = option.limit }, - label = { Text(option.label) } - ) - } - } - } - }, - confirmButton = { - Button( - onClick = { - val trimmed = url.trim() - if (trimmed.isNotEmpty()) { - onDownload( - trimmed, fileName.trim(), - selectedSpeed, selectedPriority - ) - } - }, - enabled = url.isNotBlank() && isValidUrl - ) { - Text("Download") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") - } - } - ) -} - -@Composable -private fun DownloadTaskItem( - task: DownloadTask, - scope: CoroutineScope, - onPause: () -> Unit, - onResume: () -> Unit, - onCancel: () -> Unit, - onRetry: () -> Unit, - onRemove: () -> Unit -) { - val state by task.state.collectAsState() - val fileName = task.request.fileName - ?: extractFilename(task.request.url).ifBlank { "download" } - val isDownloading = state is DownloadState.Downloading || - state is DownloadState.Pending - val isPaused = state is DownloadState.Paused - val speedLimit = task.request.speedLimit - val priority = task.request.priority - - Card( - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), - modifier = Modifier - .fillMaxWidth() - .then( - if (isDownloading || isPaused) { - Modifier.clickable { - if (isDownloading) onPause() else onResume() - } - } else { - Modifier - } - ) - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - StatusIndicator(state) - Column(modifier = Modifier.weight(1f)) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = fileName, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false) - ) - if (priority != DownloadPriority.NORMAL) { - PriorityBadge(priority) - } - } - Text( - text = task.request.url, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - - when (val s = state) { - is DownloadState.Downloading -> { - val progress = s.progress - val pct = (progress.percent * 100).coerceIn(0f, 100f) - LinearProgressIndicator( - progress = { progress.percent }, - modifier = Modifier.fillMaxWidth() - ) - Text( - text = buildString { - append("${pct.toInt()}%") - append( - " \u00b7 ${formatBytes(progress.downloadedBytes)}" - ) - append(" / ${formatBytes(progress.totalBytes)}") - if (progress.bytesPerSecond > 0) { - append( - " \u00b7 " + - "${formatBytes(progress.bytesPerSecond)}/s" - ) - } - if (!speedLimit.isUnlimited) { - append( - " (limit: " + - "${formatBytes(speedLimit.bytesPerSecond)}/s)" - ) - } - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - SpeedLimitSlider(task = task, scope = scope) - } - - is DownloadState.Scheduled -> { - Text( - text = "Scheduled \u2014 waiting for start time\u2026", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - is DownloadState.Queued -> { - Text( - text = "Queued \u2014 waiting for download slot\u2026", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - PrioritySelector(task = task, scope = scope) - } - - is DownloadState.Pending -> { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - Text( - text = "Preparing download\u2026", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - is DownloadState.Paused -> { - val progress = s.progress - if (progress.totalBytes > 0) { - val pausedPct = - (progress.percent * 100).coerceIn(0f, 100f) - LinearProgressIndicator( - progress = { progress.percent }, - modifier = Modifier.fillMaxWidth(), - trackColor = - MaterialTheme.colorScheme.surfaceVariant - ) - Text( - text = "Paused \u00b7 ${pausedPct.toInt()}%" + - " \u00b7 " + - "${formatBytes(progress.downloadedBytes)} / " + - formatBytes(progress.totalBytes), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } else { - Text( - text = "Paused", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - is DownloadState.Completed -> { - Text( - text = "Download complete", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.tertiary - ) - } - - is DownloadState.Failed -> { - Text( - text = "Failed: ${s.error.message}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - - is DownloadState.Canceled -> { - Text( - text = "Canceled", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - is DownloadState.Idle -> { - Text( - text = "Waiting", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - TaskActionButtons( - state = state, - onPause = onPause, - onResume = onResume, - onCancel = onCancel, - onRetry = onRetry, - onRemove = onRemove - ) - } - } -} - -@Composable -private fun PriorityBadge(priority: DownloadPriority) { - val color = when (priority) { - DownloadPriority.LOW -> - MaterialTheme.colorScheme.surfaceVariant - - DownloadPriority.NORMAL -> - MaterialTheme.colorScheme.secondaryContainer - - DownloadPriority.HIGH -> - MaterialTheme.colorScheme.tertiaryContainer - - DownloadPriority.URGENT -> - MaterialTheme.colorScheme.errorContainer - } - val textColor = when (priority) { - DownloadPriority.LOW -> - MaterialTheme.colorScheme.onSurfaceVariant - - DownloadPriority.NORMAL -> - MaterialTheme.colorScheme.onSecondaryContainer - - DownloadPriority.HIGH -> - MaterialTheme.colorScheme.onTertiaryContainer - - DownloadPriority.URGENT -> - MaterialTheme.colorScheme.onErrorContainer - } - Box( - modifier = Modifier - .background( - color = color, - shape = MaterialTheme.shapes.small - ) - .padding(horizontal = 6.dp, vertical = 2.dp) - ) { - Text( - text = priorityLabel(priority), - style = MaterialTheme.typography.labelSmall, - color = textColor - ) - } -} - -@Composable -private fun PrioritySelector( - task: DownloadTask, - scope: CoroutineScope -) { - var currentPriority by remember { - mutableStateOf(task.request.priority) - } - Column { - Text( - text = "Change priority:", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(4.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - DownloadPriority.entries.forEach { priority -> - FilterChip( - selected = currentPriority == priority, - onClick = { - currentPriority = priority - scope.launch { task.setPriority(priority) } - }, - label = { - Text( - text = priorityLabel(priority), - style = MaterialTheme.typography.labelSmall - ) - } - ) - } - } - } -} - -@Composable -private fun SpeedLimitSlider( - task: DownloadTask, - scope: CoroutineScope -) { - // Slider steps: 0=Unlimited, 1=512KB, 2=1MB, 3=2MB, 4=5MB, 5=10MB - val steps = listOf( - 0L, 512 * 1024L, 1_048_576L, 2_097_152L, - 5_242_880L, 10_485_760L - ) - val initial = task.request.speedLimit.bytesPerSecond - val initialIndex = steps.indexOfLast { it <= initial } - .coerceAtLeast(0).toFloat() - var sliderValue by remember { mutableStateOf(initialIndex) } - - Column { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = "Limit:", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Slider( - value = sliderValue, - onValueChange = { sliderValue = it }, - onValueChangeFinished = { - val idx = sliderValue.toInt().coerceIn(0, steps.lastIndex) - val bps = steps[idx] - val limit = if (bps == 0L) SpeedLimit.Unlimited - else SpeedLimit.of(bps) - scope.launch { task.setSpeedLimit(limit) } - }, - valueRange = 0f..steps.lastIndex.toFloat(), - steps = steps.size - 2, - modifier = Modifier.weight(1f) - ) - val idx = sliderValue.toInt().coerceIn(0, steps.lastIndex) - Text( - text = if (steps[idx] == 0L) "Unlimited" - else "${formatBytes(steps[idx])}/s", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} - -@Composable -private fun StatusIndicator(state: DownloadState) { - val bgColor = when (state) { - is DownloadState.Downloading, - is DownloadState.Pending -> MaterialTheme.colorScheme.primaryContainer - - is DownloadState.Completed -> MaterialTheme.colorScheme.tertiaryContainer - is DownloadState.Failed -> MaterialTheme.colorScheme.errorContainer - is DownloadState.Paused -> MaterialTheme.colorScheme.secondaryContainer - else -> MaterialTheme.colorScheme.surfaceVariant - } - val fgColor = when (state) { - is DownloadState.Downloading, - is DownloadState.Pending -> MaterialTheme.colorScheme.onPrimaryContainer - - is DownloadState.Completed -> - MaterialTheme.colorScheme.onTertiaryContainer - - is DownloadState.Failed -> MaterialTheme.colorScheme.onErrorContainer - is DownloadState.Paused -> - MaterialTheme.colorScheme.onSecondaryContainer - - else -> MaterialTheme.colorScheme.onSurfaceVariant - } - val label = when (state) { - is DownloadState.Idle -> "--" - is DownloadState.Scheduled -> "SC" - is DownloadState.Queued -> "Q" - is DownloadState.Pending -> ".." - is DownloadState.Downloading -> "DL" - is DownloadState.Paused -> "||" - is DownloadState.Completed -> "OK" - is DownloadState.Failed -> "!!" - is DownloadState.Canceled -> "X" - } - Box( - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(bgColor), - contentAlignment = Alignment.Center - ) { - Text( - text = label, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold, - color = fgColor - ) - } -} - -@Composable -private fun TaskActionButtons( - state: DownloadState, - onPause: () -> Unit, - onResume: () -> Unit, - onCancel: () -> Unit, - onRetry: () -> Unit, - onRemove: () -> Unit -) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - when (state) { - is DownloadState.Downloading, - is DownloadState.Pending -> { - FilledTonalIconButton(onClick = onPause) { - Icon(Icons.Filled.Pause, contentDescription = "Pause") - } - IconButton( - onClick = onCancel, - colors = IconButtonDefaults.iconButtonColors( - contentColor = MaterialTheme.colorScheme.error - ) - ) { - Icon(Icons.Filled.Close, contentDescription = "Cancel") - } - } - - is DownloadState.Paused -> { - FilledTonalIconButton(onClick = onResume) { - Icon( - Icons.Filled.PlayArrow, - contentDescription = "Resume" - ) - } - IconButton( - onClick = onCancel, - colors = IconButtonDefaults.iconButtonColors( - contentColor = MaterialTheme.colorScheme.error - ) - ) { - Icon(Icons.Filled.Close, contentDescription = "Cancel") - } - IconButton( - onClick = onRemove, - colors = IconButtonDefaults.iconButtonColors( - contentColor = MaterialTheme.colorScheme.error - ) - ) { - Icon(Icons.Filled.Delete, contentDescription = "Remove") - } - } - - is DownloadState.Completed -> { - IconButton( - onClick = onRemove, - colors = IconButtonDefaults.iconButtonColors( - contentColor = MaterialTheme.colorScheme.error - ) - ) { - Icon(Icons.Filled.Delete, contentDescription = "Remove") - } - } - - is DownloadState.Failed, - is DownloadState.Canceled -> { - FilledTonalIconButton(onClick = onRetry) { - Icon(Icons.Filled.Refresh, contentDescription = "Retry") - } - IconButton( - onClick = onRemove, - colors = IconButtonDefaults.iconButtonColors( - contentColor = MaterialTheme.colorScheme.error - ) - ) { - Icon(Icons.Filled.Delete, contentDescription = "Remove") - } - } - - is DownloadState.Scheduled, - is DownloadState.Queued -> { - IconButton( - onClick = onCancel, - colors = IconButtonDefaults.iconButtonColors( - contentColor = MaterialTheme.colorScheme.error - ) - ) { - Icon(Icons.Filled.Close, contentDescription = "Cancel") - } - } - - is DownloadState.Idle -> {} - } - } -} - -// -- Backend selector UI -- - -@Composable -private fun ConnectionStatusDot(state: ConnectionState) { - val color = when (state) { - is ConnectionState.Connected -> - MaterialTheme.colorScheme.tertiary - is ConnectionState.Connecting -> - MaterialTheme.colorScheme.secondary - is ConnectionState.Disconnected -> - MaterialTheme.colorScheme.error - } - Box( - modifier = Modifier - .size(8.dp) - .clip(CircleShape) - .background(color) - ) -} - -@Composable -private fun ConnectionStatusChip( - state: ConnectionState, - isActive: Boolean = false -) { - val (label, bgColor, textColor) = when (state) { - is ConnectionState.Connected -> Triple( - "Connected", - MaterialTheme.colorScheme.tertiaryContainer, - MaterialTheme.colorScheme.onTertiaryContainer - ) - is ConnectionState.Connecting -> Triple( - "Connecting", - MaterialTheme.colorScheme.secondaryContainer, - MaterialTheme.colorScheme.onSecondaryContainer - ) - is ConnectionState.Disconnected -> if (isActive) { - Triple( - "Disconnected", - MaterialTheme.colorScheme.errorContainer, - MaterialTheme.colorScheme.onErrorContainer - ) - } else { - Triple( - "Not connected", - MaterialTheme.colorScheme.surfaceVariant, - MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - Box( - modifier = Modifier - .background( - color = bgColor, - shape = MaterialTheme.shapes.small - ) - .padding(horizontal = 6.dp, vertical = 2.dp) - ) { - Text( - text = label, - style = MaterialTheme.typography.labelSmall, - color = textColor - ) - } -} - -private fun backendConfigIcon(config: BackendConfig): ImageVector { - return when (config) { - is BackendConfig.Embedded -> Icons.Filled.PhoneAndroid - is BackendConfig.Remote -> Icons.Filled.Cloud - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun BackendSelectorSheet( - backendManager: BackendManager, - activeBackendId: String?, - switchingBackendId: String?, - onSelectBackend: (BackendEntry) -> Unit, - onRemoveBackend: (BackendEntry) -> Unit, - onAddRemoteServer: () -> Unit, - onDismiss: () -> Unit -) { - val sheetState = rememberModalBottomSheetState() - val backends by backendManager.backends.collectAsState() - val serverState by backendManager.serverState.collectAsState() - - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 24.dp) - ) { - Text( - text = "Select Backend", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding( - horizontal = 24.dp, vertical = 8.dp - ) - ) - - backends.forEach { entry -> - val entryConnectionState by - entry.connectionState.collectAsState() - val isActive = entry.id == activeBackendId - val isSwitching = entry.id == switchingBackendId - - ListItem( - modifier = Modifier.clickable( - enabled = !isSwitching && switchingBackendId == null - ) { - onSelectBackend(entry) - }, - headlineContent = { - Text( - text = entry.label, - fontWeight = if (isActive) { - FontWeight.SemiBold - } else { - FontWeight.Normal - } - ) - }, - leadingContent = { - Icon( - imageVector = backendConfigIcon(entry.config), - contentDescription = entry.label, - tint = if (isActive) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } - ) - }, - supportingContent = { - Column { - ConnectionStatusChip( - state = entryConnectionState, - isActive = isActive - ) - // Server controls inside the Embedded entry - if (entry.isEmbedded && - backendManager.isLocalServerSupported - ) { - EmbeddedServerControls( - serverState = serverState, - onStartServer = { port, token -> - backendManager.startServer(port, token) - }, - onStopServer = { backendManager.stopServer() } - ) - } - } - }, - trailingContent = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - if (isSwitching) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp - ) - } else if (isActive) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = "Active", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp) - ) - } - if (!entry.isEmbedded) { - IconButton( - onClick = { onRemoveBackend(entry) } - ) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = "Remove", - tint = - MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - ) - } - - HorizontalDivider( - modifier = Modifier.padding(vertical = 8.dp) - ) - - ListItem( - modifier = Modifier.clickable { onAddRemoteServer() }, - headlineContent = { Text("Add Remote Server") }, - leadingContent = { - Icon( - imageVector = Icons.Filled.Add, - contentDescription = "Add remote server", - tint = MaterialTheme.colorScheme.primary - ) - } - ) - } - } -} - -@Composable -private fun EmbeddedServerControls( - serverState: ServerState, - onStartServer: (port: Int, token: String?) -> Unit, - onStopServer: () -> Unit -) { - when (serverState) { - is ServerState.Running -> { - Row( - modifier = Modifier.padding(top = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = "Server on :${serverState.port}", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary - ) - FilledTonalIconButton( - onClick = onStopServer, - modifier = Modifier.size(24.dp), - colors = IconButtonDefaults.filledTonalIconButtonColors( - containerColor = - MaterialTheme.colorScheme.errorContainer, - contentColor = - MaterialTheme.colorScheme.onErrorContainer - ) - ) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = "Stop server", - modifier = Modifier.size(14.dp) - ) - } - } - } - is ServerState.Stopped -> { - TextButton( - onClick = { onStartServer(8642, null) }, - modifier = Modifier.padding(top = 2.dp), - contentPadding = PaddingValues( - horizontal = 8.dp, vertical = 0.dp - ) - ) { - Icon( - imageVector = Icons.Filled.Computer, - contentDescription = null, - modifier = Modifier.size(14.dp) - ) - Spacer(Modifier.size(4.dp)) - Text( - text = "Start Server", - style = MaterialTheme.typography.labelSmall - ) - } - } - } -} - -@Composable -private fun AddRemoteServerDialog( - onDismiss: () -> Unit, - onAdd: (host: String, port: Int, token: String?) -> Unit -) { - var host by remember { mutableStateOf("") } - var port by remember { mutableStateOf("8642") } - var token by remember { mutableStateOf("") } - val isValidHost = host.isNotBlank() - val isValidPort = port.toIntOrNull()?.let { - it in 1..65535 - } ?: false - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Add Remote Server") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - OutlinedTextField( - value = host, - onValueChange = { host = it }, - modifier = Modifier.fillMaxWidth(), - label = { Text("Host") }, - singleLine = true, - placeholder = { Text("192.168.1.5") } - ) - OutlinedTextField( - value = port, - onValueChange = { port = it }, - modifier = Modifier.fillMaxWidth(), - label = { Text("Port") }, - singleLine = true, - placeholder = { Text("8642") }, - isError = port.isNotBlank() && !isValidPort, - supportingText = if (port.isNotBlank() && - !isValidPort - ) { - { Text("Port must be 1-65535") } - } else { - null - } - ) - OutlinedTextField( - value = token, - onValueChange = { token = it }, - modifier = Modifier.fillMaxWidth(), - label = { Text("API Token") }, - singleLine = true, - placeholder = { Text("Optional") } - ) - } - }, - confirmButton = { - Button( - onClick = { - onAdd( - host.trim(), - port.toIntOrNull() ?: 8642, - token.trim().ifBlank { null } - ) - }, - enabled = isValidHost && isValidPort - ) { - Text("Add") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") - } - } - ) -} - -private fun extractFilename(url: String): String { - val path = url.trim() - .substringBefore("?") - .substringBefore("#") - .trimEnd('/') - .substringAfterLast("/") - return path.ifBlank { "" } -} - -private fun startDownload( - scope: CoroutineScope, - kdown: KDownApi, - url: String, - directory: String, - fileName: String?, - speedLimit: SpeedLimit = SpeedLimit.Unlimited, - priority: DownloadPriority = DownloadPriority.NORMAL, - onError: (String) -> Unit = {} -) { - scope.launch { - runCatching { - val request = DownloadRequest( - url = url, - directory = directory, - fileName = fileName, - connections = 4, - speedLimit = speedLimit, - priority = priority - ) - kdown.download(request) - }.onFailure { e -> - onError(e.message ?: "Failed to start download") - } - } -} - -private fun priorityLabel(priority: DownloadPriority): String { - return when (priority) { - DownloadPriority.LOW -> "Low" - DownloadPriority.NORMAL -> "Normal" - DownloadPriority.HIGH -> "High" - DownloadPriority.URGENT -> "Urgent" - } -} - -private fun formatBytes(bytes: Long): String { - if (bytes < 0) return "--" - val kb = 1024L - val mb = kb * 1024 - val gb = mb * 1024 - return when { - bytes < kb -> "$bytes B" - bytes < mb -> { - val tenths = (bytes * 10 + kb / 2) / kb - "${tenths / 10}.${tenths % 10} KB" - } - - bytes < gb -> { - val tenths = (bytes * 10 + mb / 2) / mb - "${tenths / 10}.${tenths % 10} MB" - } - - else -> { - val hundredths = (bytes * 100 + gb / 2) / gb - "${hundredths / 100}.${ - (hundredths % 100) - .toString().padStart(2, '0') - } GB" - } + KDownTheme { + AppShell(backendManager) } } diff --git a/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/state/AppState.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/state/AppState.kt new file mode 100644 index 00000000..67357d5c --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/state/AppState.kt @@ -0,0 +1,182 @@ +package com.linroid.kdown.app.state + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.linroid.kdown.api.DownloadPriority +import com.linroid.kdown.api.DownloadRequest +import com.linroid.kdown.api.DownloadState +import com.linroid.kdown.api.DownloadTask +import com.linroid.kdown.api.KDownApi +import com.linroid.kdown.api.KDownVersion +import com.linroid.kdown.api.SpeedLimit +import com.linroid.kdown.app.backend.BackendEntry +import com.linroid.kdown.app.backend.BackendManager +import com.linroid.kdown.app.backend.ServerState +import com.linroid.kdown.remote.ConnectionState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@OptIn(ExperimentalCoroutinesApi::class) +class AppState( + val backendManager: BackendManager, + private val scope: CoroutineScope +) { + val activeApi: StateFlow = + backendManager.activeApi + val activeBackend: StateFlow = + backendManager.activeBackend + val backends: StateFlow> = + backendManager.backends + val serverState: StateFlow = + backendManager.serverState + + val connectionState: StateFlow = + activeBackend.flatMapLatest { entry -> + entry?.connectionState + ?: kotlinx.coroutines.flow.MutableStateFlow( + ConnectionState.Disconnected() + ) + }.stateIn( + scope, + SharingStarted.WhileSubscribed(5000), + ConnectionState.Disconnected() + ) + + val tasks: StateFlow> = + activeApi.flatMapLatest { it.tasks }.stateIn( + scope, + SharingStarted.WhileSubscribed(5000), + emptyList() + ) + + val version: StateFlow = + activeApi.flatMapLatest { it.version }.stateIn( + scope, + SharingStarted.WhileSubscribed(5000), + KDownVersion( + KDownVersion.DEFAULT, + KDownVersion.DEFAULT + ) + ) + + val sortedTasks: StateFlow> = + tasks.map { it.sortedByDescending { t -> t.createdAt } } + .stateIn( + scope, + SharingStarted.WhileSubscribed(5000), + emptyList() + ) + + // UI state + var statusFilter by mutableStateOf(StatusFilter.All) + var errorMessage by mutableStateOf(null) + var showAddDialog by mutableStateOf(false) + var showBackendSelector by mutableStateOf(false) + var showAddRemoteDialog by mutableStateOf(false) + var switchingBackendId by mutableStateOf(null) + + fun startDownload( + url: String, + fileName: String, + speedLimit: SpeedLimit, + priority: DownloadPriority + ) { + scope.launch { + runCatching { + val request = DownloadRequest( + url = url, + directory = "downloads", + fileName = fileName.ifBlank { null }, + connections = 4, + speedLimit = speedLimit, + priority = priority + ) + activeApi.value.download(request) + }.onFailure { e -> + errorMessage = + e.message ?: "Failed to start download" + } + } + } + + fun switchBackend(id: String) { + if (id == activeBackend.value?.id || + switchingBackendId != null + ) return + switchingBackendId = id + scope.launch { + try { + backendManager.switchTo(id) + showBackendSelector = false + } catch (e: Exception) { + errorMessage = + "Failed to switch backend: ${e.message}" + } finally { + switchingBackendId = null + } + } + } + + fun addRemoteServer( + host: String, + port: Int, + token: String? + ) { + try { + backendManager.addRemote(host, port, token) + } catch (e: Exception) { + errorMessage = + "Failed to add remote server: ${e.message}" + } + } + + fun removeBackend(id: String) { + scope.launch { + try { + backendManager.removeBackend(id) + } catch (e: Exception) { + errorMessage = + "Failed to remove backend: ${e.message}" + } + } + } + + fun pauseAll() { + scope.launch { + tasks.value.forEach { task -> + if (task.state.value.isActive) task.pause() + } + } + } + + fun resumeAll() { + scope.launch { + tasks.value.forEach { task -> + if (task.state.value is DownloadState.Paused) { + task.resume() + } + } + } + } + + fun clearCompleted() { + scope.launch { + tasks.value.forEach { task -> + if (task.state.value is DownloadState.Completed) { + task.remove() + } + } + } + } + + fun dismissError() { + errorMessage = null + } +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/state/StatusFilter.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/state/StatusFilter.kt new file mode 100644 index 00000000..9de2dfab --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/state/StatusFilter.kt @@ -0,0 +1,24 @@ +package com.linroid.kdown.app.state + +import com.linroid.kdown.api.DownloadState + +enum class StatusFilter(val label: String) { + All("All"), + Downloading("Downloading"), + Completed("Completed"), + Paused("Paused"), + Failed("Failed"); + + fun matches(state: DownloadState): Boolean = when (this) { + All -> true + Downloading -> state is DownloadState.Downloading || + state is DownloadState.Idle || + state is DownloadState.Pending || + state is DownloadState.Queued || + state is DownloadState.Scheduled + Paused -> state is DownloadState.Paused + Completed -> state is DownloadState.Completed + Failed -> state is DownloadState.Failed || + state is DownloadState.Canceled + } +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/theme/Color.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/theme/Color.kt new file mode 100644 index 00000000..1953c2af --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/theme/Color.kt @@ -0,0 +1,166 @@ +package com.linroid.kdown.app.theme + +import androidx.compose.ui.graphics.Color +import com.linroid.kdown.api.DownloadState + +// Surface palette (deep blue-gray) +val KDownBackground = Color(0xFF0F1419) +val KDownSurface = Color(0xFF1A2028) +val KDownSurfaceVariant = Color(0xFF242D38) +val KDownSurfaceContainer = Color(0xFF1E2630) +val KDownSurfaceContainerHigh = Color(0xFF283040) +val KDownOnSurface = Color(0xFFE2E8F0) +val KDownOnSurfaceVariant = Color(0xFF8899AA) +val KDownOutline = Color(0xFF4A5568) +val KDownOutlineVariant = Color(0xFF2D3748) + +// Primary accent (teal-blue) +val KDownPrimary = Color(0xFF4FC3F7) +val KDownPrimaryContainer = Color(0xFF1A3A4A) +val KDownOnPrimary = Color(0xFF0F1419) +val KDownOnPrimaryContainer = Color(0xFFB3E5FC) + +// Secondary (teal) +val KDownSecondary = Color(0xFF80CBC4) +val KDownSecondaryContainer = Color(0xFF1A3A38) +val KDownOnSecondary = Color(0xFF0F1419) +val KDownOnSecondaryContainer = Color(0xFFB2DFDB) + +// Tertiary (success/green) +val KDownTertiary = Color(0xFF66BB6A) +val KDownTertiaryContainer = Color(0xFF1B3A2B) +val KDownOnTertiary = Color(0xFF0F1419) +val KDownOnTertiaryContainer = Color(0xFFA5D6A7) + +// Error (red) +val KDownError = Color(0xFFEF5350) +val KDownErrorContainer = Color(0xFF3A1B1B) +val KDownOnError = Color(0xFF0F1419) +val KDownOnErrorContainer = Color(0xFFEF9A9A) + +// Light theme surface palette +val KDownLightBackground = Color(0xFFF8FAFC) +val KDownLightSurface = Color(0xFFFFFFFF) +val KDownLightSurfaceVariant = Color(0xFFE2E8F0) +val KDownLightSurfaceContainer = Color(0xFFF1F5F9) +val KDownLightSurfaceContainerHigh = Color(0xFFE2E8F0) +val KDownLightOnSurface = Color(0xFF1A202C) +val KDownLightOnSurfaceVariant = Color(0xFF4A5568) +val KDownLightOutline = Color(0xFF94A3B8) +val KDownLightOutlineVariant = Color(0xFFCBD5E1) + +// Light primary +val KDownLightPrimary = Color(0xFF0277BD) +val KDownLightPrimaryContainer = Color(0xFFB3E5FC) +val KDownLightOnPrimary = Color(0xFFFFFFFF) +val KDownLightOnPrimaryContainer = Color(0xFF01579B) + +// Light secondary +val KDownLightSecondary = Color(0xFF00796B) +val KDownLightSecondaryContainer = Color(0xFFB2DFDB) +val KDownLightOnSecondary = Color(0xFFFFFFFF) +val KDownLightOnSecondaryContainer = Color(0xFF004D40) + +// Light tertiary +val KDownLightTertiary = Color(0xFF2E7D32) +val KDownLightTertiaryContainer = Color(0xFFA5D6A7) +val KDownLightOnTertiary = Color(0xFFFFFFFF) +val KDownLightOnTertiaryContainer = Color(0xFF1B5E20) + +// Light error +val KDownLightError = Color(0xFFC62828) +val KDownLightErrorContainer = Color(0xFFEF9A9A) +val KDownLightOnError = Color(0xFFFFFFFF) +val KDownLightOnErrorContainer = Color(0xFF8B0000) + +// State-specific color pairs +data class StateColorPair( + val foreground: Color, + val background: Color +) + +data class DownloadStateColors( + val downloading: StateColorPair, + val pending: StateColorPair, + val queued: StateColorPair, + val scheduled: StateColorPair, + val paused: StateColorPair, + val completed: StateColorPair, + val failed: StateColorPair, + val canceled: StateColorPair, + val idle: StateColorPair +) { + fun forState(state: DownloadState): StateColorPair { + return when (state) { + is DownloadState.Downloading -> downloading + is DownloadState.Pending -> pending + is DownloadState.Queued -> queued + is DownloadState.Scheduled -> scheduled + is DownloadState.Paused -> paused + is DownloadState.Completed -> completed + is DownloadState.Failed -> failed + is DownloadState.Canceled -> canceled + is DownloadState.Idle -> idle + } + } +} + +val DarkStateColors = DownloadStateColors( + downloading = StateColorPair( + Color(0xFF4FC3F7), Color(0xFF1B3A4F) + ), + pending = StateColorPair( + Color(0xFF4FC3F7), Color(0xFF1B3A4F) + ), + queued = StateColorPair( + Color(0xFF90A4AE), Color(0xFF2A2D35) + ), + scheduled = StateColorPair( + Color(0xFF90A4AE), Color(0xFF2A2D35) + ), + paused = StateColorPair( + Color(0xFFFFB74D), Color(0xFF3A2E1B) + ), + completed = StateColorPair( + Color(0xFF66BB6A), Color(0xFF1B3A2B) + ), + failed = StateColorPair( + Color(0xFFEF5350), Color(0xFF3A1B1B) + ), + canceled = StateColorPair( + Color(0xFF78909C), Color(0xFF2A2D35) + ), + idle = StateColorPair( + Color(0xFF78909C), Color(0xFF2A2D35) + ) +) + +val LightStateColors = DownloadStateColors( + downloading = StateColorPair( + Color(0xFF0277BD), Color(0xFFE1F5FE) + ), + pending = StateColorPair( + Color(0xFF0277BD), Color(0xFFE1F5FE) + ), + queued = StateColorPair( + Color(0xFF546E7A), Color(0xFFECEFF1) + ), + scheduled = StateColorPair( + Color(0xFF546E7A), Color(0xFFECEFF1) + ), + paused = StateColorPair( + Color(0xFFEF6C00), Color(0xFFFFF3E0) + ), + completed = StateColorPair( + Color(0xFF2E7D32), Color(0xFFE8F5E9) + ), + failed = StateColorPair( + Color(0xFFC62828), Color(0xFFFFEBEE) + ), + canceled = StateColorPair( + Color(0xFF78909C), Color(0xFFECEFF1) + ), + idle = StateColorPair( + Color(0xFF78909C), Color(0xFFECEFF1) + ) +) diff --git a/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/theme/Theme.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/theme/Theme.kt new file mode 100644 index 00000000..937bb858 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/theme/Theme.kt @@ -0,0 +1,101 @@ +package com.linroid.kdown.app.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf + +val LocalDownloadStateColors = + staticCompositionLocalOf { DarkStateColors } + +private val KDownDarkColorScheme = darkColorScheme( + primary = KDownPrimary, + onPrimary = KDownOnPrimary, + primaryContainer = KDownPrimaryContainer, + onPrimaryContainer = KDownOnPrimaryContainer, + secondary = KDownSecondary, + onSecondary = KDownOnSecondary, + secondaryContainer = KDownSecondaryContainer, + onSecondaryContainer = KDownOnSecondaryContainer, + tertiary = KDownTertiary, + onTertiary = KDownOnTertiary, + tertiaryContainer = KDownTertiaryContainer, + onTertiaryContainer = KDownOnTertiaryContainer, + error = KDownError, + onError = KDownOnError, + errorContainer = KDownErrorContainer, + onErrorContainer = KDownOnErrorContainer, + background = KDownBackground, + onBackground = KDownOnSurface, + surface = KDownSurface, + onSurface = KDownOnSurface, + surfaceVariant = KDownSurfaceVariant, + onSurfaceVariant = KDownOnSurfaceVariant, + surfaceContainerLowest = KDownBackground, + surfaceContainerLow = KDownSurface, + surfaceContainer = KDownSurfaceContainer, + surfaceContainerHigh = KDownSurfaceContainerHigh, + surfaceContainerHighest = KDownSurfaceVariant, + outline = KDownOutline, + outlineVariant = KDownOutlineVariant +) + +private val KDownLightColorScheme = lightColorScheme( + primary = KDownLightPrimary, + onPrimary = KDownLightOnPrimary, + primaryContainer = KDownLightPrimaryContainer, + onPrimaryContainer = KDownLightOnPrimaryContainer, + secondary = KDownLightSecondary, + onSecondary = KDownLightOnSecondary, + secondaryContainer = KDownLightSecondaryContainer, + onSecondaryContainer = KDownLightOnSecondaryContainer, + tertiary = KDownLightTertiary, + onTertiary = KDownLightOnTertiary, + tertiaryContainer = KDownLightTertiaryContainer, + onTertiaryContainer = KDownLightOnTertiaryContainer, + error = KDownLightError, + onError = KDownLightOnError, + errorContainer = KDownLightErrorContainer, + onErrorContainer = KDownLightOnErrorContainer, + background = KDownLightBackground, + onBackground = KDownLightOnSurface, + surface = KDownLightSurface, + onSurface = KDownLightOnSurface, + surfaceVariant = KDownLightSurfaceVariant, + onSurfaceVariant = KDownLightOnSurfaceVariant, + surfaceContainerLowest = KDownLightSurface, + surfaceContainerLow = KDownLightBackground, + surfaceContainer = KDownLightSurfaceContainer, + surfaceContainerHigh = KDownLightSurfaceContainerHigh, + surfaceContainerHighest = KDownLightSurfaceVariant, + outline = KDownLightOutline, + outlineVariant = KDownLightOutlineVariant +) + +@Composable +fun KDownTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colorScheme = if (darkTheme) { + KDownDarkColorScheme + } else { + KDownLightColorScheme + } + val stateColors = if (darkTheme) { + DarkStateColors + } else { + LightStateColors + } + CompositionLocalProvider( + LocalDownloadStateColors provides stateColors + ) { + MaterialTheme( + colorScheme = colorScheme, + content = content + ) + } +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/AppShell.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/AppShell.kt new file mode 100644 index 00000000..1f340950 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/AppShell.kt @@ -0,0 +1,288 @@ +package com.linroid.kdown.app.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.linroid.kdown.api.DownloadState +import com.linroid.kdown.app.backend.BackendManager +import com.linroid.kdown.app.state.AppState +import com.linroid.kdown.app.state.StatusFilter +import com.linroid.kdown.app.ui.dialog.AddDownloadDialog +import com.linroid.kdown.app.ui.dialog.AddRemoteServerDialog +import com.linroid.kdown.app.ui.dialog.BackendSelectorSheet +import com.linroid.kdown.app.ui.list.DownloadList +import com.linroid.kdown.app.ui.sidebar.SidebarNavigation +import com.linroid.kdown.app.ui.sidebar.SpeedStatusBar +import com.linroid.kdown.app.ui.toolbar.BatchActionBar +import com.linroid.kdown.app.ui.toolbar.countTasksByFilter + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppShell(backendManager: BackendManager) { + val scope = rememberCoroutineScope() + val appState = remember(backendManager) { + AppState(backendManager, scope) + } + + DisposableEffect(Unit) { + onDispose { backendManager.close() } + } + + val sortedTasks by appState.sortedTasks.collectAsState() + val version by appState.version.collectAsState() + val activeBackend by + appState.activeBackend.collectAsState() + val connectionState by + appState.connectionState.collectAsState() + val serverState by + appState.serverState.collectAsState() + + // Collect all task states for filtering/counts + val taskStates = remember { mutableStateMapOf() } + val currentTaskIds = sortedTasks.map { it.taskId }.toSet() + taskStates.keys.removeAll { it !in currentTaskIds } + sortedTasks.forEach { task -> + val state by task.state.collectAsState() + taskStates[task.taskId] = state + } + + val filteredTasks by remember { + derivedStateOf { + if (appState.statusFilter == StatusFilter.All) { + sortedTasks + } else { + sortedTasks.filter { task -> + val state = taskStates[task.taskId] + state != null && + appState.statusFilter.matches(state) + } + } + } + } + + val taskCounts by remember { + derivedStateOf { + StatusFilter.entries.associateWith { filter -> + countTasksByFilter(filter, taskStates) + } + } + } + + val hasActive by remember { + derivedStateOf { + taskStates.values.any { it.isActive } + } + } + val hasPaused by remember { + derivedStateOf { + taskStates.values.any { + it is DownloadState.Paused + } + } + } + val hasCompleted by remember(taskStates) { + derivedStateOf { + taskStates.values.any { + it is DownloadState.Completed + } + } + } + + val activeDownloadCount by remember(taskStates) { + derivedStateOf { + taskStates.values.count { it.isActive } + } + } + val totalSpeed by remember(taskStates) { + derivedStateOf { + taskStates.values.sumOf { state -> + if (state is DownloadState.Downloading) { + state.progress.bytesPerSecond + } else { + 0L + } + } + } + } + + Column(modifier = Modifier.fillMaxSize()) { + // Main content: sidebar + list + Row(modifier = Modifier.weight(1f)) { + // Left sidebar + SidebarNavigation( + selectedFilter = appState.statusFilter, + taskCounts = taskCounts, + onFilterSelect = { appState.statusFilter = it }, + onAddClick = { + appState.showAddDialog = true + } + ) + + VerticalDivider( + color = MaterialTheme.colorScheme.outlineVariant + ) + + // Right content area + Column(modifier = Modifier.weight(1f)) { + // Top bar with title and batch actions + TopAppBar( + title = { + Text( + text = appState.statusFilter.label, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + }, + actions = { + BatchActionBar( + hasActiveDownloads = hasActive, + hasPausedDownloads = hasPaused, + hasCompletedDownloads = hasCompleted, + onPauseAll = { appState.pauseAll() }, + onResumeAll = { appState.resumeAll() }, + onClearCompleted = { + appState.clearCompleted() + } + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = + MaterialTheme.colorScheme.surface + ) + ) + + // Error banner + if (appState.errorMessage != null) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme + .errorContainer + ), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = + Alignment.CenterVertically, + horizontalArrangement = + Arrangement.spacedBy(12.dp) + ) { + Text( + text = appState.errorMessage ?: "", + style = + MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme + .onErrorContainer, + modifier = Modifier.weight(1f) + ) + TextButton( + onClick = { appState.dismissError() } + ) { + Text("Dismiss") + } + } + } + } + + // Download list + DownloadList( + tasks = filteredTasks, + isEmpty = sortedTasks.isEmpty() && + appState.errorMessage == null, + isFilterEmpty = filteredTasks.isEmpty() && + sortedTasks.isNotEmpty(), + selectedFilter = appState.statusFilter, + scope = scope, + onAddClick = { + appState.showAddDialog = true + }, + modifier = Modifier.weight(1f) + ) + } + } + + // Bottom speed status bar + SpeedStatusBar( + activeDownloads = activeDownloadCount, + totalSpeed = totalSpeed, + backendLabel = activeBackend?.label, + connectionState = connectionState, + onBackendClick = { + appState.showBackendSelector = true + } + ) + } + + // Dialogs + if (appState.showAddDialog) { + AddDownloadDialog( + onDismiss = { appState.showAddDialog = false }, + onDownload = { url, fileName, speedLimit, priority -> + appState.showAddDialog = false + appState.dismissError() + appState.startDownload( + url, fileName, speedLimit, priority + ) + } + ) + } + + if (appState.showBackendSelector) { + BackendSelectorSheet( + backendManager = backendManager, + activeBackendId = activeBackend?.id, + switchingBackendId = appState.switchingBackendId, + serverState = serverState, + onSelectBackend = { entry -> + appState.switchBackend(entry.id) + }, + onRemoveBackend = { entry -> + appState.removeBackend(entry.id) + }, + onAddRemoteServer = { + appState.showAddRemoteDialog = true + }, + onDismiss = { + appState.showBackendSelector = false + } + ) + } + + if (appState.showAddRemoteDialog) { + AddRemoteServerDialog( + onDismiss = { + appState.showAddRemoteDialog = false + }, + onAdd = { host, port, token -> + appState.showAddRemoteDialog = false + appState.addRemoteServer(host, port, token) + } + ) + } +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/common/ConnectionStatusDot.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/common/ConnectionStatusDot.kt new file mode 100644 index 00000000..302811a4 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/common/ConnectionStatusDot.kt @@ -0,0 +1,78 @@ +package com.linroid.kdown.app.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.linroid.kdown.remote.ConnectionState + +@Composable +fun ConnectionStatusDot(state: ConnectionState) { + val color = when (state) { + is ConnectionState.Connected -> + MaterialTheme.colorScheme.tertiary + is ConnectionState.Connecting -> + MaterialTheme.colorScheme.secondary + is ConnectionState.Disconnected -> + MaterialTheme.colorScheme.error + } + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(color) + ) +} + +@Composable +fun ConnectionStatusChip( + state: ConnectionState, + isActive: Boolean = false +) { + val (label, bgColor, textColor) = when (state) { + is ConnectionState.Connected -> Triple( + "Connected", + MaterialTheme.colorScheme.tertiaryContainer, + MaterialTheme.colorScheme.onTertiaryContainer + ) + is ConnectionState.Connecting -> Triple( + "Connecting", + MaterialTheme.colorScheme.secondaryContainer, + MaterialTheme.colorScheme.onSecondaryContainer + ) + is ConnectionState.Disconnected -> if (isActive) { + Triple( + "Disconnected", + MaterialTheme.colorScheme.errorContainer, + MaterialTheme.colorScheme.onErrorContainer + ) + } else { + Triple( + "Not connected", + MaterialTheme.colorScheme.surfaceVariant, + MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Box( + modifier = Modifier + .background( + color = bgColor, + shape = MaterialTheme.shapes.small + ) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = textColor + ) + } +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/common/PriorityBadge.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/common/PriorityBadge.kt new file mode 100644 index 00000000..f1213cbb --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/common/PriorityBadge.kt @@ -0,0 +1,50 @@ +package com.linroid.kdown.app.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.linroid.kdown.api.DownloadPriority +import com.linroid.kdown.app.util.priorityLabel + +@Composable +fun PriorityBadge(priority: DownloadPriority) { + val color = when (priority) { + DownloadPriority.LOW -> + MaterialTheme.colorScheme.surfaceVariant + DownloadPriority.NORMAL -> + MaterialTheme.colorScheme.secondaryContainer + DownloadPriority.HIGH -> + MaterialTheme.colorScheme.tertiaryContainer + DownloadPriority.URGENT -> + MaterialTheme.colorScheme.errorContainer + } + val textColor = when (priority) { + DownloadPriority.LOW -> + MaterialTheme.colorScheme.onSurfaceVariant + DownloadPriority.NORMAL -> + MaterialTheme.colorScheme.onSecondaryContainer + DownloadPriority.HIGH -> + MaterialTheme.colorScheme.onTertiaryContainer + DownloadPriority.URGENT -> + MaterialTheme.colorScheme.onErrorContainer + } + Box( + modifier = Modifier + .background( + color = color, + shape = MaterialTheme.shapes.small + ) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = priorityLabel(priority), + style = MaterialTheme.typography.labelSmall, + color = textColor + ) + } +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/common/PrioritySelector.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/common/PrioritySelector.kt new file mode 100644 index 00000000..f27c4f6d --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/common/PrioritySelector.kt @@ -0,0 +1,82 @@ +package com.linroid.kdown.app.ui.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LowPriority +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.linroid.kdown.api.DownloadPriority +import com.linroid.kdown.api.DownloadTask +import com.linroid.kdown.app.util.priorityLabel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Composable +fun PriorityIcon( + active: Boolean, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + IconButton( + onClick = onClick, + modifier = modifier.size(28.dp), + colors = IconButtonDefaults.iconButtonColors( + contentColor = if (active || selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + ) { + Icon( + Icons.Filled.LowPriority, + contentDescription = "Priority", + modifier = Modifier.size(16.dp) + ) + } +} + +@Composable +fun PriorityPanel( + task: DownloadTask, + scope: CoroutineScope, + modifier: Modifier = Modifier +) { + var currentPriority by remember { + mutableStateOf(task.request.priority) + } + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + DownloadPriority.entries.forEach { priority -> + FilterChip( + selected = currentPriority == priority, + onClick = { + currentPriority = priority + scope.launch { task.setPriority(priority) } + }, + label = { + Text( + text = priorityLabel(priority), + style = MaterialTheme.typography.labelSmall + ) + } + ) + } + } +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/common/ScheduleToggle.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/common/ScheduleToggle.kt new file mode 100644 index 00000000..eb92c90f --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/common/ScheduleToggle.kt @@ -0,0 +1,92 @@ +package com.linroid.kdown.app.ui.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.linroid.kdown.api.DownloadSchedule +import com.linroid.kdown.api.DownloadTask +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +private data class ScheduleOption( + val label: String, + val schedule: DownloadSchedule +) + +private val scheduleOptions = listOf( + ScheduleOption("Now", DownloadSchedule.Immediate), + ScheduleOption("5 min", DownloadSchedule.AfterDelay(5.minutes)), + ScheduleOption("15 min", DownloadSchedule.AfterDelay(15.minutes)), + ScheduleOption("30 min", DownloadSchedule.AfterDelay(30.minutes)), + ScheduleOption("1 hour", DownloadSchedule.AfterDelay(1.hours)), + ScheduleOption("3 hours", DownloadSchedule.AfterDelay(3.hours)) +) + +@Composable +fun ScheduleIcon( + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + IconButton( + onClick = onClick, + modifier = modifier.size(28.dp), + colors = IconButtonDefaults.iconButtonColors( + contentColor = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + ) { + Icon( + Icons.Filled.Schedule, + contentDescription = "Schedule", + modifier = Modifier.size(16.dp) + ) + } +} + +@Composable +fun SchedulePanel( + task: DownloadTask, + scope: CoroutineScope, + onScheduled: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + scheduleOptions.forEach { option -> + FilterChip( + selected = false, + onClick = { + scope.launch { + task.reschedule(option.schedule) + onScheduled() + } + }, + label = { + Text( + text = option.label, + style = MaterialTheme.typography.labelSmall + ) + } + ) + } + } +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/common/SpeedLimitSlider.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/common/SpeedLimitSlider.kt new file mode 100644 index 00000000..76983a76 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/common/SpeedLimitSlider.kt @@ -0,0 +1,105 @@ +package com.linroid.kdown.app.ui.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Speed +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.linroid.kdown.api.DownloadTask +import com.linroid.kdown.api.SpeedLimit +import com.linroid.kdown.app.util.formatBytes +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +private val speedSteps = listOf( + 0L, 512 * 1024L, 1_048_576L, 2_097_152L, + 5_242_880L, 10_485_760L +) + +@Composable +fun SpeedLimitIcon( + active: Boolean, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + IconButton( + onClick = onClick, + modifier = modifier.size(28.dp), + colors = IconButtonDefaults.iconButtonColors( + contentColor = if (active || selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + ) { + Icon( + Icons.Filled.Speed, + contentDescription = "Speed limit", + modifier = Modifier.size(16.dp) + ) + } +} + +@Composable +fun SpeedLimitPanel( + task: DownloadTask, + scope: CoroutineScope, + modifier: Modifier = Modifier +) { + val initial = task.request.speedLimit.bytesPerSecond + val initialIndex = speedSteps + .indexOfLast { it <= initial } + .coerceAtLeast(0).toFloat() + var sliderValue by remember { mutableStateOf(initialIndex) } + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Limit:", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Slider( + value = sliderValue, + onValueChange = { sliderValue = it }, + onValueChangeFinished = { + val idx = sliderValue.toInt() + .coerceIn(0, speedSteps.lastIndex) + val bps = speedSteps[idx] + val limit = if (bps == 0L) SpeedLimit.Unlimited + else SpeedLimit.of(bps) + scope.launch { task.setSpeedLimit(limit) } + }, + valueRange = 0f..speedSteps.lastIndex.toFloat(), + steps = speedSteps.size - 2, + modifier = Modifier.weight(1f) + ) + val idx = sliderValue.toInt() + .coerceIn(0, speedSteps.lastIndex) + Text( + text = if (speedSteps[idx] == 0L) "Unlimited" + else "${formatBytes(speedSteps[idx])}/s", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/common/StatusIndicator.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/common/StatusIndicator.kt new file mode 100644 index 00000000..0ab1256c --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/common/StatusIndicator.kt @@ -0,0 +1,78 @@ +package com.linroid.kdown.app.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.HourglassTop +import androidx.compose.material.icons.filled.Inbox +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import com.linroid.kdown.api.DownloadState +import com.linroid.kdown.app.theme.LocalDownloadStateColors + +@Composable +fun StatusIndicator( + state: DownloadState, + modifier: Modifier = Modifier +) { + val stateColors = LocalDownloadStateColors.current + val colors = stateColors.forState(state) + val icon = stateIcon(state) + + Box( + modifier = modifier + .size(36.dp) + .clip(CircleShape) + .background(colors.background), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = stateLabel(state), + tint = colors.foreground, + modifier = Modifier.size(20.dp) + ) + } +} + +private fun stateIcon(state: DownloadState): ImageVector { + return when (state) { + is DownloadState.Downloading -> Icons.Filled.ArrowDownward + is DownloadState.Pending -> Icons.Filled.HourglassTop + is DownloadState.Queued -> Icons.Filled.Inbox + is DownloadState.Scheduled -> Icons.Filled.Schedule + is DownloadState.Paused -> Icons.Filled.Pause + is DownloadState.Completed -> Icons.Filled.CheckCircle + is DownloadState.Failed -> Icons.Filled.ErrorOutline + is DownloadState.Canceled -> Icons.Filled.Cancel + is DownloadState.Idle -> Icons.Filled.RadioButtonUnchecked + } +} + +private fun stateLabel(state: DownloadState): String { + return when (state) { + is DownloadState.Downloading -> "Downloading" + is DownloadState.Pending -> "Pending" + is DownloadState.Queued -> "Queued" + is DownloadState.Scheduled -> "Scheduled" + is DownloadState.Paused -> "Paused" + is DownloadState.Completed -> "Completed" + is DownloadState.Failed -> "Failed" + is DownloadState.Canceled -> "Canceled" + is DownloadState.Idle -> "Idle" + } +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/common/TaskSettingsPanel.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/common/TaskSettingsPanel.kt new file mode 100644 index 00000000..7cfe87b2 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/common/TaskSettingsPanel.kt @@ -0,0 +1,174 @@ +package com.linroid.kdown.app.ui.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.linroid.kdown.api.DownloadTask +import com.linroid.kdown.app.util.formatBytes +import com.linroid.kdown.app.util.priorityLabel + +@Composable +fun TaskSettingsIcon( + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + IconButton( + onClick = onClick, + modifier = modifier.size(28.dp), + colors = IconButtonDefaults.iconButtonColors( + contentColor = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + ) { + Icon( + Icons.Filled.Info, + contentDescription = "Task info", + modifier = Modifier.size(16.dp) + ) + } +} + +@Composable +fun TaskSettingsPanel( + task: DownloadTask, + modifier: Modifier = Modifier +) { + val segments by task.segments.collectAsState() + + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(8.dp) + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + CopyableInfoRow("URL", task.request.url) + if (task.request.directory != null) { + InfoRow("Directory", task.request.directory!!) + } + if (task.request.fileName != null) { + InfoRow("File name", task.request.fileName!!) + } + InfoRow( + "Connections", + task.request.connections.toString() + ) + if (segments.isNotEmpty()) { + val completed = segments.count { it.isComplete } + InfoRow( + "Segments", + "$completed / ${segments.size} complete" + ) + } + InfoRow( + "Priority", + priorityLabel(task.request.priority) + ) + val limit = task.request.speedLimit + InfoRow( + "Speed limit", + if (limit.isUnlimited) "Unlimited" + else "${formatBytes(limit.bytesPerSecond)}/s" + ) + InfoRow("Task ID", task.taskId) + } + } +} + +@Composable +private fun CopyableInfoRow( + label: String, + value: String, + modifier: Modifier = Modifier +) { + val clipboardManager = LocalClipboardManager.current + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(0.3f) + ) + Text( + text = value, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(0.7f) + ) + IconButton( + onClick = { + clipboardManager.setText(AnnotatedString(value)) + }, + modifier = Modifier.size(24.dp) + ) { + Icon( + Icons.Filled.ContentCopy, + contentDescription = "Copy", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun InfoRow( + label: String, + value: String, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(0.3f) + ) + Text( + text = value, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(0.7f) + ) + } +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/dialog/AddDownloadDialog.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/dialog/AddDownloadDialog.kt new file mode 100644 index 00000000..d65e2755 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/dialog/AddDownloadDialog.kt @@ -0,0 +1,172 @@ +package com.linroid.kdown.app.ui.dialog + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.linroid.kdown.api.DownloadPriority +import com.linroid.kdown.api.SpeedLimit +import com.linroid.kdown.app.util.extractFilename +import com.linroid.kdown.app.util.priorityLabel + +private data class SpeedOption( + val label: String, + val limit: SpeedLimit +) + +private val speedOptions = listOf( + SpeedOption("Unlimited", SpeedLimit.Unlimited), + SpeedOption("1 MB/s", SpeedLimit.mbps(1)), + SpeedOption("5 MB/s", SpeedLimit.mbps(5)), + SpeedOption("10 MB/s", SpeedLimit.mbps(10)) +) + +@Composable +fun AddDownloadDialog( + onDismiss: () -> Unit, + onDownload: ( + url: String, + fileName: String, + SpeedLimit, + DownloadPriority + ) -> Unit +) { + var url by remember { mutableStateOf("") } + var fileName by remember { mutableStateOf("") } + var selectedSpeed by remember { + mutableStateOf(SpeedLimit.Unlimited) + } + var selectedPriority by remember { + mutableStateOf(DownloadPriority.NORMAL) + } + val isValidUrl = url.isBlank() || + url.trim().startsWith("http://") || + url.trim().startsWith("https://") + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add download") }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedTextField( + value = url, + onValueChange = { + url = it + fileName = extractFilename(it) + }, + modifier = Modifier.fillMaxWidth(), + label = { Text("URL") }, + singleLine = true, + placeholder = { + Text("https://example.com/file.zip") + }, + isError = !isValidUrl, + supportingText = if (!isValidUrl) { + { + Text( + "URL must start with " + + "http:// or https://" + ) + } + } else { + null + } + ) + OutlinedTextField( + value = fileName, + onValueChange = { fileName = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Save as") }, + singleLine = true, + placeholder = { + Text("Auto-detected from URL") + }, + supportingText = if (fileName.isBlank() && + url.isNotBlank() + ) { + { Text("Will be extracted from URL") } + } else { + null + } + ) + Text( + text = "Priority", + style = MaterialTheme.typography.labelMedium, + color = + MaterialTheme.colorScheme.onSurfaceVariant + ) + Row( + horizontalArrangement = + Arrangement.spacedBy(8.dp) + ) { + DownloadPriority.entries.forEach { priority -> + FilterChip( + selected = selectedPriority == priority, + onClick = { selectedPriority = priority }, + label = { + Text(priorityLabel(priority)) + } + ) + } + } + Text( + text = "Speed limit", + style = MaterialTheme.typography.labelMedium, + color = + MaterialTheme.colorScheme.onSurfaceVariant + ) + Row( + horizontalArrangement = + Arrangement.spacedBy(8.dp) + ) { + speedOptions.forEach { option -> + FilterChip( + selected = selectedSpeed == option.limit, + onClick = { + selectedSpeed = option.limit + }, + label = { Text(option.label) } + ) + } + } + } + }, + confirmButton = { + Button( + onClick = { + val trimmed = url.trim() + if (trimmed.isNotEmpty()) { + onDownload( + trimmed, fileName.trim(), + selectedSpeed, selectedPriority + ) + } + }, + enabled = url.isNotBlank() && isValidUrl + ) { + Text("Download") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/dialog/AddRemoteServerDialog.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/dialog/AddRemoteServerDialog.kt new file mode 100644 index 00000000..74d3a29f --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/dialog/AddRemoteServerDialog.kt @@ -0,0 +1,93 @@ +package com.linroid.kdown.app.ui.dialog + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun AddRemoteServerDialog( + onDismiss: () -> Unit, + onAdd: (host: String, port: Int, token: String?) -> Unit +) { + var host by remember { mutableStateOf("") } + var port by remember { mutableStateOf("8642") } + var token by remember { mutableStateOf("") } + val isValidHost = host.isNotBlank() + val isValidPort = port.toIntOrNull()?.let { + it in 1..65535 + } ?: false + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add Remote Server") }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedTextField( + value = host, + onValueChange = { host = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Host") }, + singleLine = true, + placeholder = { Text("192.168.1.5") } + ) + OutlinedTextField( + value = port, + onValueChange = { port = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Port") }, + singleLine = true, + placeholder = { Text("8642") }, + isError = port.isNotBlank() && !isValidPort, + supportingText = if (port.isNotBlank() && + !isValidPort + ) { + { Text("Port must be 1-65535") } + } else { + null + } + ) + OutlinedTextField( + value = token, + onValueChange = { token = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("API Token") }, + singleLine = true, + placeholder = { Text("Optional") } + ) + } + }, + confirmButton = { + Button( + onClick = { + onAdd( + host.trim(), + port.toIntOrNull() ?: 8642, + token.trim().ifBlank { null } + ) + }, + enabled = isValidHost && isValidPort + ) { + Text("Add") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/dialog/BackendSelectorSheet.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/dialog/BackendSelectorSheet.kt new file mode 100644 index 00000000..d4bc570f --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/dialog/BackendSelectorSheet.kt @@ -0,0 +1,204 @@ +package com.linroid.kdown.app.ui.dialog + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material.icons.filled.PhoneAndroid +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.linroid.kdown.app.backend.BackendConfig +import com.linroid.kdown.app.backend.BackendEntry +import com.linroid.kdown.app.backend.BackendManager +import com.linroid.kdown.app.backend.ServerState +import com.linroid.kdown.app.ui.common.ConnectionStatusChip + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BackendSelectorSheet( + backendManager: BackendManager, + activeBackendId: String?, + switchingBackendId: String?, + serverState: ServerState, + onSelectBackend: (BackendEntry) -> Unit, + onRemoveBackend: (BackendEntry) -> Unit, + onAddRemoteServer: () -> Unit, + onDismiss: () -> Unit +) { + val sheetState = rememberModalBottomSheetState() + val backends by backendManager.backends.collectAsState() + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp) + ) { + Text( + text = "Select Backend", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding( + horizontal = 24.dp, vertical = 8.dp + ) + ) + + backends.forEach { entry -> + val entryConnectionState by + entry.connectionState.collectAsState() + val isActive = entry.id == activeBackendId + val isSwitching = entry.id == switchingBackendId + + ListItem( + modifier = Modifier.clickable( + enabled = !isSwitching && + switchingBackendId == null + ) { + onSelectBackend(entry) + }, + headlineContent = { + Text( + text = entry.label, + fontWeight = if (isActive) { + FontWeight.SemiBold + } else { + FontWeight.Normal + } + ) + }, + leadingContent = { + Icon( + imageVector = backendConfigIcon( + entry.config + ), + contentDescription = entry.label, + tint = if (isActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme + .onSurfaceVariant + } + ) + }, + supportingContent = { + Column { + ConnectionStatusChip( + state = entryConnectionState, + isActive = isActive + ) + if (entry.isEmbedded && + backendManager.isLocalServerSupported + ) { + EmbeddedServerControls( + serverState = serverState, + onStartServer = { port, token -> + backendManager.startServer( + port, token + ) + }, + onStopServer = { + backendManager.stopServer() + } + ) + } + } + }, + trailingContent = { + Row( + verticalAlignment = + Alignment.CenterVertically, + horizontalArrangement = + Arrangement.spacedBy(4.dp) + ) { + if (isSwitching) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + } else if (isActive) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = "Active", + tint = + MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + } + if (!entry.isEmbedded) { + IconButton( + onClick = { + onRemoveBackend(entry) + } + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Remove", + tint = MaterialTheme.colorScheme + .onSurfaceVariant + ) + } + } + } + } + ) + } + + HorizontalDivider( + modifier = Modifier.padding(vertical = 8.dp) + ) + + ListItem( + modifier = Modifier.clickable { + onAddRemoteServer() + }, + headlineContent = { + Text("Add Remote Server") + }, + leadingContent = { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = "Add remote server", + tint = MaterialTheme.colorScheme.primary + ) + } + ) + } + } +} + +private fun backendConfigIcon( + config: BackendConfig +): ImageVector { + return when (config) { + is BackendConfig.Embedded -> + Icons.Filled.PhoneAndroid + is BackendConfig.Remote -> Icons.Filled.Cloud + } +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/dialog/EmbeddedServerControls.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/dialog/EmbeddedServerControls.kt new file mode 100644 index 00000000..2c7d4fd5 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/dialog/EmbeddedServerControls.kt @@ -0,0 +1,83 @@ +package com.linroid.kdown.app.ui.dialog + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Computer +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.linroid.kdown.app.backend.ServerState + +@Composable +fun EmbeddedServerControls( + serverState: ServerState, + onStartServer: (port: Int, token: String?) -> Unit, + onStopServer: () -> Unit +) { + when (serverState) { + is ServerState.Running -> { + Row( + modifier = Modifier.padding(top = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = + Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Server on :${serverState.port}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + FilledTonalIconButton( + onClick = onStopServer, + modifier = Modifier.size(24.dp), + colors = IconButtonDefaults + .filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme + .errorContainer, + contentColor = MaterialTheme.colorScheme + .onErrorContainer + ) + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Stop server", + modifier = Modifier.size(14.dp) + ) + } + } + } + is ServerState.Stopped -> { + TextButton( + onClick = { onStartServer(8642, null) }, + modifier = Modifier.padding(top = 2.dp), + contentPadding = PaddingValues( + horizontal = 8.dp, vertical = 0.dp + ) + ) { + Icon( + imageVector = Icons.Filled.Computer, + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + Spacer(Modifier.size(4.dp)) + Text( + text = "Start Server", + style = MaterialTheme.typography.labelSmall + ) + } + } + } +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/list/DownloadList.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/list/DownloadList.kt new file mode 100644 index 00000000..058349b6 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/list/DownloadList.kt @@ -0,0 +1,152 @@ +package com.linroid.kdown.app.ui.list + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CloudDownload +import androidx.compose.material.icons.outlined.FilterList +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.linroid.kdown.api.DownloadTask +import com.linroid.kdown.app.state.StatusFilter +import kotlinx.coroutines.CoroutineScope + +@Composable +fun DownloadList( + tasks: List, + isEmpty: Boolean, + isFilterEmpty: Boolean, + selectedFilter: StatusFilter, + scope: CoroutineScope, + onAddClick: () -> Unit, + modifier: Modifier = Modifier +) { + when { + isEmpty -> { + EmptyState( + modifier = modifier.fillMaxSize(), + onAddClick = onAddClick + ) + } + isFilterEmpty -> { + EmptyFilterState( + filter = selectedFilter, + modifier = modifier.fillMaxSize() + ) + } + else -> { + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = 8.dp, + bottom = 16.dp + ), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items( + items = tasks, + key = { it.taskId } + ) { task -> + DownloadListItem( + task = task, + scope = scope + ) + } + } + } + } +} + +@Composable +private fun EmptyState( + modifier: Modifier = Modifier, + onAddClick: () -> Unit +) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = Icons.Outlined.CloudDownload, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary + .copy(alpha = 0.6f) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "No downloads yet", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Click \"New Task\" to start downloading", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onAddClick) { + Text("New Task") + } + } + } +} + +@Composable +private fun EmptyFilterState( + filter: StatusFilter, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = Icons.Outlined.FilterList, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + .copy(alpha = 0.4f) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "No ${filter.label.lowercase()} downloads", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "Try a different category", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline + ) + } + } +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/list/DownloadListItem.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/list/DownloadListItem.kt new file mode 100644 index 00000000..c4a87685 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/list/DownloadListItem.kt @@ -0,0 +1,251 @@ +package com.linroid.kdown.app.ui.list + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.linroid.kdown.api.DownloadPriority +import com.linroid.kdown.api.DownloadState +import com.linroid.kdown.api.DownloadTask +import com.linroid.kdown.app.ui.common.PriorityBadge +import com.linroid.kdown.app.ui.common.PriorityIcon +import com.linroid.kdown.app.ui.common.PriorityPanel +import com.linroid.kdown.app.ui.common.ScheduleIcon +import com.linroid.kdown.app.ui.common.SchedulePanel +import com.linroid.kdown.app.ui.common.SpeedLimitIcon +import com.linroid.kdown.app.ui.common.SpeedLimitPanel +import com.linroid.kdown.app.ui.common.StatusIndicator +import com.linroid.kdown.app.ui.common.TaskSettingsIcon +import com.linroid.kdown.app.ui.common.TaskSettingsPanel +import com.linroid.kdown.app.util.extractFilename +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +private enum class ExpandedPanel { + None, SpeedLimit, Priority, Schedule, Settings +} + +@Composable +fun DownloadListItem( + task: DownloadTask, + scope: CoroutineScope, + modifier: Modifier = Modifier +) { + val state by task.state.collectAsState() + val fileName = task.request.fileName + ?: extractFilename(task.request.url) + .ifBlank { "download" } + val isDownloading = state is DownloadState.Downloading || + state is DownloadState.Pending + val isPaused = state is DownloadState.Paused + val showToggles = isDownloading || isPaused || + state is DownloadState.Queued || + state is DownloadState.Scheduled + var expanded by remember { mutableStateOf(ExpandedPanel.None) } + + Card( + onClick = { + scope.launch { + if (isDownloading) task.pause() + else task.resume() + } + }, + enabled = isDownloading || isPaused, + colors = CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme.surfaceContainer, + disabledContainerColor = + MaterialTheme.colorScheme.surfaceContainer + ), + modifier = modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding( + horizontal = 16.dp, vertical = 14.dp + ), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + // Header: status icon + file info + actions + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = + Arrangement.spacedBy(12.dp) + ) { + StatusIndicator(state) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = + Arrangement.spacedBy(4.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = + Arrangement.spacedBy(8.dp) + ) { + Text( + text = fileName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight( + 1f, fill = false + ) + ) + if (task.request.priority != + DownloadPriority.NORMAL + ) { + PriorityBadge(task.request.priority) + } + } + Text( + text = task.request.url, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme + .onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + TaskActionButtons( + state = state, + onPause = { + scope.launch { task.pause() } + }, + onResume = { + scope.launch { task.resume() } + }, + onCancel = { + scope.launch { task.cancel() } + }, + onRetry = { + scope.launch { task.resume() } + }, + onRemove = { + scope.launch { task.remove() } + } + ) + } + + // State-specific content + ProgressSection( + state = state, + speedLimit = task.request.speedLimit + ) + + // Toggle icon row + if (showToggles) { + Row( + horizontalArrangement = + Arrangement.spacedBy(4.dp) + ) { + SpeedLimitIcon( + active = + !task.request.speedLimit.isUnlimited, + selected = + expanded == ExpandedPanel.SpeedLimit, + onClick = { + expanded = if (expanded == + ExpandedPanel.SpeedLimit + ) { + ExpandedPanel.None + } else { + ExpandedPanel.SpeedLimit + } + } + ) + PriorityIcon( + active = task.request.priority != + DownloadPriority.NORMAL, + selected = + expanded == ExpandedPanel.Priority, + onClick = { + expanded = if (expanded == + ExpandedPanel.Priority + ) { + ExpandedPanel.None + } else { + ExpandedPanel.Priority + } + } + ) + ScheduleIcon( + selected = + expanded == ExpandedPanel.Schedule, + onClick = { + expanded = if (expanded == + ExpandedPanel.Schedule + ) { + ExpandedPanel.None + } else { + ExpandedPanel.Schedule + } + } + ) + TaskSettingsIcon( + selected = + expanded == ExpandedPanel.Settings, + onClick = { + expanded = if (expanded == + ExpandedPanel.Settings + ) { + ExpandedPanel.None + } else { + ExpandedPanel.Settings + } + } + ) + } + + // Expanded panel below icons + AnimatedContent( + targetState = expanded, + transitionSpec = { + expandVertically() togetherWith shrinkVertically() + } + ) { panel -> + when (panel) { + ExpandedPanel.SpeedLimit -> SpeedLimitPanel( + task = task, scope = scope + ) + ExpandedPanel.Priority -> PriorityPanel( + task = task, scope = scope + ) + ExpandedPanel.Schedule -> SchedulePanel( + task = task, + scope = scope, + onScheduled = { + expanded = ExpandedPanel.None + } + ) + ExpandedPanel.Settings -> TaskSettingsPanel( + task = task + ) + ExpandedPanel.None -> {} + } + } + } + } + } +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/list/ProgressSection.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/list/ProgressSection.kt new file mode 100644 index 00000000..c867b8b7 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/list/ProgressSection.kt @@ -0,0 +1,162 @@ +package com.linroid.kdown.app.ui.list + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import com.linroid.kdown.api.DownloadState +import com.linroid.kdown.api.SpeedLimit +import com.linroid.kdown.app.theme.LocalDownloadStateColors +import com.linroid.kdown.app.util.formatBytes +import com.linroid.kdown.app.util.formatEta + +@Composable +fun ProgressSection( + state: DownloadState, + speedLimit: SpeedLimit +) { + val stateColors = LocalDownloadStateColors.current + + when (state) { + is DownloadState.Downloading -> { + val progress = state.progress + val pct = (progress.percent * 100).coerceIn(0f, 100f) + val colors = stateColors.downloading + LinearProgressIndicator( + progress = { progress.percent }, + modifier = Modifier.fillMaxWidth(), + color = colors.foreground, + trackColor = MaterialTheme.colorScheme.surfaceVariant + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = buildString { + append("${pct.toInt()}%") + append( + " \u00b7 ${formatBytes(progress.downloadedBytes)}" + ) + append(" / ${formatBytes(progress.totalBytes)}") + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = buildString { + if (progress.bytesPerSecond > 0) { + append( + "${formatBytes(progress.bytesPerSecond)}/s" + ) + if (progress.totalBytes > 0) { + val remaining = progress.totalBytes - + progress.downloadedBytes + val eta = + remaining / progress.bytesPerSecond + val etaStr = formatEta(eta) + if (etaStr.isNotEmpty()) { + append(" \u00b7 $etaStr") + } + } + } + if (!speedLimit.isUnlimited) { + append( + " (limit: " + + "${formatBytes(speedLimit.bytesPerSecond)}" + + "/s)" + ) + } + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + is DownloadState.Paused -> { + val progress = state.progress + val colors = stateColors.paused + if (progress.totalBytes > 0) { + val pct = + (progress.percent * 100).coerceIn(0f, 100f) + LinearProgressIndicator( + progress = { progress.percent }, + modifier = Modifier.fillMaxWidth(), + color = colors.foreground, + trackColor = + MaterialTheme.colorScheme.surfaceVariant + ) + Text( + text = "Paused \u00b7 ${pct.toInt()}%" + + " \u00b7 " + + "${formatBytes(progress.downloadedBytes)}" + + " / ${formatBytes(progress.totalBytes)}", + style = MaterialTheme.typography.bodySmall, + color = colors.foreground + ) + } else { + Text( + text = "Paused", + style = MaterialTheme.typography.bodySmall, + color = colors.foreground + ) + } + } + is DownloadState.Pending -> { + val colors = stateColors.pending + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = colors.foreground, + trackColor = MaterialTheme.colorScheme.surfaceVariant + ) + Text( + text = "Preparing download\u2026", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + is DownloadState.Queued -> { + Text( + text = "Queued \u2014 waiting for download slot\u2026", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + is DownloadState.Scheduled -> { + Text( + text = "Scheduled \u2014 waiting for start time\u2026", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + is DownloadState.Completed -> { + Text( + text = "Download complete", + style = MaterialTheme.typography.bodySmall, + color = stateColors.completed.foreground + ) + } + is DownloadState.Failed -> { + Text( + text = "Failed: ${state.error.message}", + style = MaterialTheme.typography.bodySmall, + color = stateColors.failed.foreground, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + is DownloadState.Canceled -> { + Text( + text = "Canceled", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + is DownloadState.Idle -> {} + } +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/list/TaskActionButtons.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/list/TaskActionButtons.kt new file mode 100644 index 00000000..ac5a2728 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/list/TaskActionButtons.kt @@ -0,0 +1,127 @@ +package com.linroid.kdown.app.ui.list + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.linroid.kdown.api.DownloadState + +@Composable +fun TaskActionButtons( + state: DownloadState, + onPause: () -> Unit, + onResume: () -> Unit, + onCancel: () -> Unit, + onRetry: () -> Unit, + onRemove: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + when (state) { + is DownloadState.Downloading, + is DownloadState.Pending -> { + ActionIcon( + icon = Icons.Filled.Pause, + description = "Pause", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + onClick = onPause + ) + ActionIcon( + icon = Icons.Filled.Close, + description = "Cancel", + tint = MaterialTheme.colorScheme.error, + onClick = onCancel + ) + } + is DownloadState.Paused -> { + ActionIcon( + icon = Icons.Filled.PlayArrow, + description = "Resume", + tint = MaterialTheme.colorScheme.primary, + onClick = onResume + ) + ActionIcon( + icon = Icons.Filled.Close, + description = "Cancel", + tint = MaterialTheme.colorScheme.error, + onClick = onCancel + ) + ActionIcon( + icon = Icons.Filled.Delete, + description = "Remove", + tint = MaterialTheme.colorScheme.error, + onClick = onRemove + ) + } + is DownloadState.Completed -> { + ActionIcon( + icon = Icons.Filled.Delete, + description = "Remove", + tint = MaterialTheme.colorScheme.error, + onClick = onRemove + ) + } + is DownloadState.Failed, + is DownloadState.Canceled -> { + ActionIcon( + icon = Icons.Filled.Refresh, + description = "Retry", + tint = MaterialTheme.colorScheme.primary, + onClick = onRetry + ) + ActionIcon( + icon = Icons.Filled.Delete, + description = "Remove", + tint = MaterialTheme.colorScheme.error, + onClick = onRemove + ) + } + is DownloadState.Scheduled, + is DownloadState.Queued -> { + ActionIcon( + icon = Icons.Filled.Close, + description = "Cancel", + tint = MaterialTheme.colorScheme.error, + onClick = onCancel + ) + } + is DownloadState.Idle -> {} + } + } +} + +@Composable +private fun ActionIcon( + icon: androidx.compose.ui.graphics.vector.ImageVector, + description: String, + tint: androidx.compose.ui.graphics.Color, + onClick: () -> Unit +) { + IconButton( + onClick = onClick, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = icon, + contentDescription = description, + modifier = Modifier.size(18.dp), + tint = tint + ) + } +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/sidebar/SidebarNavigation.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/sidebar/SidebarNavigation.kt new file mode 100644 index 00000000..b86ff3cb --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/sidebar/SidebarNavigation.kt @@ -0,0 +1,200 @@ +package com.linroid.kdown.app.ui.sidebar + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.linroid.kdown.app.state.StatusFilter + +private val SIDEBAR_WIDTH = 200.dp + +@Composable +fun SidebarNavigation( + selectedFilter: StatusFilter, + taskCounts: Map, + onFilterSelect: (StatusFilter) -> Unit, + onAddClick: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .width(SIDEBAR_WIDTH) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.surfaceContainerLow) + .padding(vertical = 12.dp) + ) { + // Add download button + FloatingActionButton( + onClick = onAddClick, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + shape = RoundedCornerShape(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Filled.Add, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Text( + text = "New Task", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + + // Category label + Text( + text = "TASKS", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding( + horizontal = 20.dp, vertical = 8.dp + ) + ) + + // Navigation items + StatusFilter.entries.forEach { filter -> + val count = taskCounts[filter] ?: 0 + SidebarItem( + icon = filterIcon(filter), + label = filter.label, + count = count, + selected = selectedFilter == filter, + onClick = { onFilterSelect(filter) } + ) + } + } +} + +@Composable +private fun SidebarItem( + icon: ImageVector, + label: String, + count: Int, + selected: Boolean, + onClick: () -> Unit +) { + val bgColor = if (selected) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) + } else { + MaterialTheme.colorScheme.surfaceContainerLow + } + val contentColor = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 1.dp) + .clip(RoundedCornerShape(8.dp)) + .background(bgColor) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = icon, + contentDescription = label, + modifier = Modifier.size(20.dp), + tint = contentColor + ) + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = if (selected) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + fontWeight = if (selected) { + FontWeight.SemiBold + } else { + FontWeight.Normal + }, + modifier = Modifier.weight(1f) + ) + if (count > 0) { + Box( + modifier = Modifier + .background( + color = if (selected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + }, + shape = CircleShape + ) + .padding(horizontal = 8.dp, vertical = 2.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = count.toString(), + style = MaterialTheme.typography.labelSmall, + color = contentColor, + fontWeight = FontWeight.SemiBold + ) + } + } + } +} + +private fun filterIcon(filter: StatusFilter): ImageVector { + return when (filter) { + StatusFilter.All -> Icons.Filled.Folder + StatusFilter.Downloading -> Icons.Filled.ArrowDownward + StatusFilter.Paused -> Icons.Filled.Pause + StatusFilter.Completed -> Icons.Filled.CheckCircle + StatusFilter.Failed -> Icons.Filled.ErrorOutline + } +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/sidebar/SpeedStatusBar.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/sidebar/SpeedStatusBar.kt new file mode 100644 index 00000000..921cfc8e --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/sidebar/SpeedStatusBar.kt @@ -0,0 +1,107 @@ +package com.linroid.kdown.app.ui.sidebar + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.Speed +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.linroid.kdown.app.ui.common.ConnectionStatusDot +import com.linroid.kdown.app.util.formatBytes +import com.linroid.kdown.remote.ConnectionState + +@Composable +fun SpeedStatusBar( + activeDownloads: Int, + totalSpeed: Long, + backendLabel: String?, + connectionState: ConnectionState, + onBackendClick: () -> Unit, + modifier: Modifier = Modifier +) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = modifier.fillMaxWidth() + ) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + // Left side: backend info + Row( + modifier = Modifier.clickable { onBackendClick() }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + ConnectionStatusDot(connectionState) + Text( + text = backendLabel ?: "Not connected", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + // Right side: speed info + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (activeDownloads > 0) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + Icons.Filled.ArrowDownward, + contentDescription = "Download speed", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = "${formatBytes(totalSpeed)}/s", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + Icons.Filled.Speed, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = if (activeDownloads > 0) { + "$activeDownloads active" + } else { + "Idle" + }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/toolbar/BatchActionBar.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/toolbar/BatchActionBar.kt new file mode 100644 index 00000000..a6b26b3b --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/toolbar/BatchActionBar.kt @@ -0,0 +1,62 @@ +package com.linroid.kdown.app.ui.toolbar + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CleaningServices +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun BatchActionBar( + hasActiveDownloads: Boolean, + hasPausedDownloads: Boolean, + hasCompletedDownloads: Boolean, + onPauseAll: () -> Unit, + onResumeAll: () -> Unit, + onClearCompleted: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + if (hasActiveDownloads) { + IconButton(onClick = onPauseAll) { + Icon( + Icons.Filled.Pause, + contentDescription = "Pause all", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + if (hasPausedDownloads) { + IconButton(onClick = onResumeAll) { + Icon( + Icons.Filled.PlayArrow, + contentDescription = "Resume all", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + if (hasCompletedDownloads) { + IconButton(onClick = onClearCompleted) { + Icon( + Icons.Filled.CleaningServices, + contentDescription = "Clear completed", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/toolbar/FilterBar.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/toolbar/FilterBar.kt new file mode 100644 index 00000000..5fa0e060 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/ui/toolbar/FilterBar.kt @@ -0,0 +1,64 @@ +package com.linroid.kdown.app.ui.toolbar + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.linroid.kdown.api.DownloadState +import com.linroid.kdown.app.state.StatusFilter + +@Composable +fun FilterBar( + selected: StatusFilter, + taskCounts: Map, + onSelect: (StatusFilter) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + StatusFilter.entries.forEach { filter -> + val count = taskCounts[filter] ?: 0 + val label = if (filter == StatusFilter.All) { + filter.label + } else { + "${filter.label} ($count)" + } + FilterChip( + selected = selected == filter, + onClick = { onSelect(filter) }, + label = { + Text( + text = label, + style = MaterialTheme.typography.labelMedium + ) + }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = + MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = + MaterialTheme.colorScheme.onPrimaryContainer + ) + ) + } + } +} + +fun countTasksByFilter( + filter: StatusFilter, + states: Map +): Int { + if (filter == StatusFilter.All) return states.size + return states.values.count { filter.matches(it) } +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/util/FormatUtils.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/util/FormatUtils.kt new file mode 100644 index 00000000..b982099a --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/util/FormatUtils.kt @@ -0,0 +1,58 @@ +package com.linroid.kdown.app.util + +import com.linroid.kdown.api.DownloadPriority + +fun extractFilename(url: String): String { + val path = url.trim() + .substringBefore("?") + .substringBefore("#") + .trimEnd('/') + .substringAfterLast("/") + return path.ifBlank { "" } +} + +fun priorityLabel(priority: DownloadPriority): String { + return when (priority) { + DownloadPriority.LOW -> "Low" + DownloadPriority.NORMAL -> "Normal" + DownloadPriority.HIGH -> "High" + DownloadPriority.URGENT -> "Urgent" + } +} + +fun formatBytes(bytes: Long): String { + if (bytes < 0) return "--" + val kb = 1024L + val mb = kb * 1024 + val gb = mb * 1024 + return when { + bytes < kb -> "$bytes B" + bytes < mb -> { + val tenths = (bytes * 10 + kb / 2) / kb + "${tenths / 10}.${tenths % 10} KB" + } + bytes < gb -> { + val tenths = (bytes * 10 + mb / 2) / mb + "${tenths / 10}.${tenths % 10} MB" + } + else -> { + val hundredths = (bytes * 100 + gb / 2) / gb + "${hundredths / 100}.${ + (hundredths % 100) + .toString().padStart(2, '0') + } GB" + } + } +} + +fun formatEta(seconds: Long): String { + if (seconds <= 0) return "" + val h = seconds / 3600 + val m = (seconds % 3600) / 60 + val s = seconds % 60 + return when { + h > 0 -> "${h}h ${m}m" + m > 0 -> "${m}m ${s}s" + else -> "${s}s" + } +} diff --git a/app/shared/src/commonTest/kotlin/com/linroid/kdown/app/FormatUtilsTest.kt b/app/shared/src/commonTest/kotlin/com/linroid/kdown/app/FormatUtilsTest.kt new file mode 100644 index 00000000..4806b8c7 --- /dev/null +++ b/app/shared/src/commonTest/kotlin/com/linroid/kdown/app/FormatUtilsTest.kt @@ -0,0 +1,283 @@ +package com.linroid.kdown.app + +import com.linroid.kdown.api.DownloadPriority +import com.linroid.kdown.app.util.extractFilename +import com.linroid.kdown.app.util.formatBytes +import com.linroid.kdown.app.util.formatEta +import com.linroid.kdown.app.util.priorityLabel +import kotlin.test.Test +import kotlin.test.assertEquals + +class FormatUtilsTest { + + // ----------------------------------------------------------- + // formatBytes + // ----------------------------------------------------------- + + @Test + fun formatBytes_zero() { + assertEquals("0 B", formatBytes(0)) + } + + @Test + fun formatBytes_oneByte() { + assertEquals("1 B", formatBytes(1)) + } + + @Test + fun formatBytes_belowOneKB() { + assertEquals("512 B", formatBytes(512)) + assertEquals("1023 B", formatBytes(1023)) + } + + @Test + fun formatBytes_exactlyOneKB() { + assertEquals("1.0 KB", formatBytes(1024)) + } + + @Test + fun formatBytes_oneAndHalfKB() { + assertEquals("1.5 KB", formatBytes(1536)) + } + + @Test + fun formatBytes_largeKB() { + // 999 KB = 999 * 1024 = 1_022_976 + val result = formatBytes(1_022_976) + assertEquals("999.0 KB", result) + } + + @Test + fun formatBytes_exactlyOneMB() { + assertEquals("1.0 MB", formatBytes(1_048_576)) + } + + @Test + fun formatBytes_oneAndHalfMB() { + assertEquals("1.5 MB", formatBytes(1_572_864)) + } + + @Test + fun formatBytes_largeMB() { + // 500 MB + assertEquals("500.0 MB", formatBytes(524_288_000)) + } + + @Test + fun formatBytes_exactlyOneGB() { + assertEquals("1.00 GB", formatBytes(1_073_741_824)) + } + + @Test + fun formatBytes_oneAndHalfGB() { + assertEquals("1.50 GB", formatBytes(1_610_612_736)) + } + + @Test + fun formatBytes_largeGB() { + // 10 GB + assertEquals("10.00 GB", formatBytes(10_737_418_240)) + } + + @Test + fun formatBytes_negativeValue() { + assertEquals("--", formatBytes(-1)) + assertEquals("--", formatBytes(-100)) + assertEquals("--", formatBytes(Long.MIN_VALUE)) + } + + // ----------------------------------------------------------- + // formatEta + // ----------------------------------------------------------- + + @Test + fun formatEta_zero() { + assertEquals("", formatEta(0)) + } + + @Test + fun formatEta_negative() { + assertEquals("", formatEta(-1)) + assertEquals("", formatEta(-100)) + } + + @Test + fun formatEta_oneSecond() { + assertEquals("1s", formatEta(1)) + } + + @Test + fun formatEta_59Seconds() { + assertEquals("59s", formatEta(59)) + } + + @Test + fun formatEta_exactlyOneMinute() { + assertEquals("1m 0s", formatEta(60)) + } + + @Test + fun formatEta_minutesAndSeconds() { + assertEquals("5m 30s", formatEta(330)) + } + + @Test + fun formatEta_59Minutes59Seconds() { + assertEquals("59m 59s", formatEta(3599)) + } + + @Test + fun formatEta_exactlyOneHour() { + assertEquals("1h 0m", formatEta(3600)) + } + + @Test + fun formatEta_oneHourOneMinuteOneSecond() { + // 3661 = 1h 1m 1s, but format is "Xh Xm" (no seconds) + assertEquals("1h 1m", formatEta(3661)) + } + + @Test + fun formatEta_largeValue() { + // 24 hours = 86400 seconds + assertEquals("24h 0m", formatEta(86400)) + } + + @Test + fun formatEta_multipleHoursAndMinutes() { + // 2h 30m = 9000 seconds + assertEquals("2h 30m", formatEta(9000)) + } + + // ----------------------------------------------------------- + // extractFilename + // ----------------------------------------------------------- + + @Test + fun extractFilename_emptyUrl() { + assertEquals("", extractFilename("")) + } + + @Test + fun extractFilename_simpleUrl() { + assertEquals( + "file.zip", + extractFilename("https://example.com/file.zip") + ) + } + + @Test + fun extractFilename_urlWithQueryParams() { + assertEquals( + "file.zip", + extractFilename( + "https://example.com/file.zip?token=abc&v=2" + ) + ) + } + + @Test + fun extractFilename_urlWithFragment() { + assertEquals( + "file.zip", + extractFilename("https://example.com/file.zip#section") + ) + } + + @Test + fun extractFilename_urlWithQueryAndFragment() { + assertEquals( + "file.zip", + extractFilename( + "https://example.com/file.zip?v=1#top" + ) + ) + } + + @Test + fun extractFilename_urlWithTrailingSlash() { + assertEquals( + "downloads", + extractFilename("https://example.com/downloads/") + ) + } + + @Test + fun extractFilename_urlWithDeepPath() { + assertEquals( + "archive.tar.gz", + extractFilename( + "https://cdn.example.com/a/b/c/archive.tar.gz" + ) + ) + } + + @Test + fun extractFilename_urlWithNoPath() { + // No path component after host, substringAfterLast("/") + // returns the hostname portion + assertEquals( + "example.com", + extractFilename("https://example.com") + ) + } + + @Test + fun extractFilename_urlWithOnlySlash() { + // Trailing slash is trimmed, falls back to hostname + assertEquals( + "example.com", + extractFilename("https://example.com/") + ) + } + + @Test + fun extractFilename_whitespaceUrl() { + assertEquals("", extractFilename(" ")) + } + + @Test + fun extractFilename_urlWithSpacePadding() { + assertEquals( + "file.zip", + extractFilename(" https://example.com/file.zip ") + ) + } + + // ----------------------------------------------------------- + // priorityLabel + // ----------------------------------------------------------- + + @Test + fun priorityLabel_low() { + assertEquals("Low", priorityLabel(DownloadPriority.LOW)) + } + + @Test + fun priorityLabel_normal() { + assertEquals("Normal", priorityLabel(DownloadPriority.NORMAL)) + } + + @Test + fun priorityLabel_high() { + assertEquals("High", priorityLabel(DownloadPriority.HIGH)) + } + + @Test + fun priorityLabel_urgent() { + assertEquals("Urgent", priorityLabel(DownloadPriority.URGENT)) + } + + @Test + fun priorityLabel_allEntriesCovered() { + // Ensure every enum entry produces a non-blank label + DownloadPriority.entries.forEach { priority -> + val label = priorityLabel(priority) + assertEquals( + true, + label.isNotBlank(), + "Priority $priority should have a non-blank label" + ) + } + } +} diff --git a/app/shared/src/commonTest/kotlin/com/linroid/kdown/app/StatusFilterTest.kt b/app/shared/src/commonTest/kotlin/com/linroid/kdown/app/StatusFilterTest.kt new file mode 100644 index 00000000..6da39639 --- /dev/null +++ b/app/shared/src/commonTest/kotlin/com/linroid/kdown/app/StatusFilterTest.kt @@ -0,0 +1,360 @@ +package com.linroid.kdown.app + +import com.linroid.kdown.api.DownloadProgress +import com.linroid.kdown.api.DownloadSchedule +import com.linroid.kdown.api.DownloadState +import com.linroid.kdown.api.KDownError +import com.linroid.kdown.app.state.StatusFilter +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class StatusFilterTest { + + private val allStates: List = listOf( + DownloadState.Idle, + DownloadState.Scheduled(DownloadSchedule.Immediate), + DownloadState.Queued, + DownloadState.Pending, + DownloadState.Downloading( + DownloadProgress( + downloadedBytes = 100, + totalBytes = 1000, + bytesPerSecond = 50 + ) + ), + DownloadState.Paused( + DownloadProgress( + downloadedBytes = 100, + totalBytes = 1000 + ) + ), + DownloadState.Completed("/path/to/file"), + DownloadState.Failed(KDownError.Network()), + DownloadState.Canceled + ) + + // ----------------------------------------------------------- + // StatusFilter.All + // ----------------------------------------------------------- + + @Test + fun all_matchesEveryState() { + allStates.forEach { state -> + assertTrue( + StatusFilter.All.matches(state), + "All should match $state" + ) + } + } + + // ----------------------------------------------------------- + // StatusFilter.Downloading + // ----------------------------------------------------------- + + @Test + fun downloading_matchesDownloadingState() { + val state = DownloadState.Downloading( + DownloadProgress(50, 100, 10) + ) + assertTrue(StatusFilter.Downloading.matches(state)) + } + + @Test + fun downloading_matchesPendingState() { + assertTrue( + StatusFilter.Downloading.matches(DownloadState.Pending) + ) + } + + @Test + fun downloading_matchesQueuedState() { + assertTrue( + StatusFilter.Downloading.matches(DownloadState.Queued) + ) + } + + @Test + fun downloading_matchesScheduledState() { + val state = DownloadState.Scheduled( + DownloadSchedule.Immediate + ) + assertTrue(StatusFilter.Downloading.matches(state)) + } + + @Test + fun downloading_rejectsPaused() { + val state = DownloadState.Paused( + DownloadProgress(50, 100) + ) + assertFalse(StatusFilter.Downloading.matches(state)) + } + + @Test + fun downloading_rejectsCompleted() { + assertFalse( + StatusFilter.Downloading.matches( + DownloadState.Completed("/file") + ) + ) + } + + @Test + fun downloading_rejectsFailed() { + assertFalse( + StatusFilter.Downloading.matches( + DownloadState.Failed(KDownError.Network()) + ) + ) + } + + @Test + fun downloading_rejectsCanceled() { + assertFalse( + StatusFilter.Downloading.matches(DownloadState.Canceled) + ) + } + + @Test + fun downloading_matchesIdleState() { + assertTrue( + StatusFilter.Downloading.matches(DownloadState.Idle) + ) + } + + // ----------------------------------------------------------- + // StatusFilter.Paused + // ----------------------------------------------------------- + + @Test + fun paused_matchesPausedState() { + val state = DownloadState.Paused( + DownloadProgress(50, 100) + ) + assertTrue(StatusFilter.Paused.matches(state)) + } + + @Test + fun paused_rejectsDownloading() { + assertFalse( + StatusFilter.Paused.matches( + DownloadState.Downloading( + DownloadProgress(50, 100, 10) + ) + ) + ) + } + + @Test + fun paused_rejectsCompleted() { + assertFalse( + StatusFilter.Paused.matches( + DownloadState.Completed("/file") + ) + ) + } + + @Test + fun paused_rejectsFailed() { + assertFalse( + StatusFilter.Paused.matches( + DownloadState.Failed(KDownError.Network()) + ) + ) + } + + @Test + fun paused_rejectsCanceled() { + assertFalse( + StatusFilter.Paused.matches(DownloadState.Canceled) + ) + } + + @Test + fun paused_rejectsIdle() { + assertFalse( + StatusFilter.Paused.matches(DownloadState.Idle) + ) + } + + @Test + fun paused_rejectsPending() { + assertFalse( + StatusFilter.Paused.matches(DownloadState.Pending) + ) + } + + @Test + fun paused_rejectsQueued() { + assertFalse( + StatusFilter.Paused.matches(DownloadState.Queued) + ) + } + + // ----------------------------------------------------------- + // StatusFilter.Completed + // ----------------------------------------------------------- + + @Test + fun completed_matchesCompletedState() { + assertTrue( + StatusFilter.Completed.matches( + DownloadState.Completed("/path/to/file.zip") + ) + ) + } + + @Test + fun completed_rejectsDownloading() { + assertFalse( + StatusFilter.Completed.matches( + DownloadState.Downloading( + DownloadProgress(50, 100, 10) + ) + ) + ) + } + + @Test + fun completed_rejectsPaused() { + assertFalse( + StatusFilter.Completed.matches( + DownloadState.Paused(DownloadProgress(50, 100)) + ) + ) + } + + @Test + fun completed_rejectsFailed() { + assertFalse( + StatusFilter.Completed.matches( + DownloadState.Failed(KDownError.Network()) + ) + ) + } + + @Test + fun completed_rejectsCanceled() { + assertFalse( + StatusFilter.Completed.matches(DownloadState.Canceled) + ) + } + + @Test + fun completed_rejectsIdle() { + assertFalse( + StatusFilter.Completed.matches(DownloadState.Idle) + ) + } + + // ----------------------------------------------------------- + // StatusFilter.Failed + // ----------------------------------------------------------- + + @Test + fun failed_matchesFailedState() { + assertTrue( + StatusFilter.Failed.matches( + DownloadState.Failed(KDownError.Network()) + ) + ) + } + + @Test + fun failed_matchesCanceledState() { + assertTrue( + StatusFilter.Failed.matches(DownloadState.Canceled) + ) + } + + @Test + fun failed_matchesFailedWithDifferentErrors() { + assertTrue( + StatusFilter.Failed.matches( + DownloadState.Failed(KDownError.Disk()) + ) + ) + assertTrue( + StatusFilter.Failed.matches( + DownloadState.Failed(KDownError.Http(404, "Not Found")) + ) + ) + assertTrue( + StatusFilter.Failed.matches( + DownloadState.Failed(KDownError.Unknown()) + ) + ) + } + + @Test + fun failed_rejectsDownloading() { + assertFalse( + StatusFilter.Failed.matches( + DownloadState.Downloading( + DownloadProgress(50, 100, 10) + ) + ) + ) + } + + @Test + fun failed_rejectsPaused() { + assertFalse( + StatusFilter.Failed.matches( + DownloadState.Paused(DownloadProgress(50, 100)) + ) + ) + } + + @Test + fun failed_rejectsCompleted() { + assertFalse( + StatusFilter.Failed.matches( + DownloadState.Completed("/file") + ) + ) + } + + @Test + fun failed_rejectsIdle() { + assertFalse( + StatusFilter.Failed.matches(DownloadState.Idle) + ) + } + + @Test + fun failed_rejectsPending() { + assertFalse( + StatusFilter.Failed.matches(DownloadState.Pending) + ) + } + + @Test + fun failed_rejectsQueued() { + assertFalse( + StatusFilter.Failed.matches(DownloadState.Queued) + ) + } + + // ----------------------------------------------------------- + // Cross-filter coverage: each state matched by exactly one + // non-All filter + // ----------------------------------------------------------- + + @Test + fun eachState_matchedByExactlyOneNonAllFilter() { + val nonAllFilters = StatusFilter.entries.filter { + it != StatusFilter.All + } + allStates.forEach { state -> + val matchingFilters = nonAllFilters.filter { + it.matches(state) + } + assertTrue( + matchingFilters.size == 1, + "State $state should match exactly one filter, " + + "but matched: $matchingFilters" + ) + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index ceebd570..b38e3974 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.composeCompiler) apply false alias(libs.plugins.kotlinJvm) apply false alias(libs.plugins.kotlinMultiplatform) apply false + alias(libs.plugins.composeHotReload) apply false alias(libs.plugins.sqldelight) apply false } diff --git a/library/core/src/commonMain/kotlin/com/linroid/kdown/core/engine/DownloadCoordinator.kt b/library/core/src/commonMain/kotlin/com/linroid/kdown/core/engine/DownloadCoordinator.kt index 63b0e43e..a53cf61c 100644 --- a/library/core/src/commonMain/kotlin/com/linroid/kdown/core/engine/DownloadCoordinator.kt +++ b/library/core/src/commonMain/kotlin/com/linroid/kdown/core/engine/DownloadCoordinator.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.withContext import kotlinx.io.files.Path import kotlinx.io.files.SystemFileSystem import kotlin.time.Clock +import kotlin.time.TimeSource internal class DownloadCoordinator( private val sourceResolver: SourceResolver, @@ -583,6 +584,9 @@ internal class DownloadCoordinator( totalBytes: Long, headers: Map ): DownloadContext { + var lastBytes = 0L + var lastMark = TimeSource.Monotonic.markNow() + var speed = 0L return DownloadContext( taskId = taskId, url = url, @@ -590,8 +594,16 @@ internal class DownloadCoordinator( fileAccessor = fileAccessor, segments = segmentsFlow, onProgress = { downloaded, total -> + val now = TimeSource.Monotonic.markNow() + val elapsed = (now - lastMark).inWholeMilliseconds + if (elapsed >= 500) { + val delta = downloaded - lastBytes + speed = if (elapsed > 0) delta * 1000 / elapsed else 0L + lastBytes = downloaded + lastMark = now + } stateFlow.value = DownloadState.Downloading( - DownloadProgress(downloaded, total) + DownloadProgress(downloaded, total, speed) ) // Update segments in task record periodically val snapshot = segmentsFlow.value