diff --git a/app/shared/src/androidMain/kotlin/com/linroid/ketch/app/platform/Platform.android.kt b/app/shared/src/androidMain/kotlin/com/linroid/ketch/app/platform/Platform.android.kt new file mode 100644 index 00000000..d9b44559 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/com/linroid/ketch/app/platform/Platform.android.kt @@ -0,0 +1,3 @@ +package com.linroid.ketch.app.platform + +actual val isMobilePlatform: Boolean = true diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/platform/Platform.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/platform/Platform.kt new file mode 100644 index 00000000..8872ab97 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/platform/Platform.kt @@ -0,0 +1,8 @@ +package com.linroid.ketch.app.platform + +/** + * True on phone/tablet form factors (Android, iOS), false on desktop/web. + * Drives UI choices that should diverge by form factor (e.g. ModalBottomSheet + * vs AlertDialog). + */ +expect val isMobilePlatform: Boolean diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/common/AdaptiveModal.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/common/AdaptiveModal.kt new file mode 100644 index 00000000..f742c9e8 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/common/AdaptiveModal.kt @@ -0,0 +1,95 @@ +package com.linroid.ketch.app.ui.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.linroid.ketch.app.platform.isMobilePlatform + +/** + * A modal surface that adapts to the current platform: a [ModalBottomSheet] on + * phone/tablet form factors and an [AlertDialog] on desktop and web. Lets + * feature code declare a modal without branching on platform. + * + * On mobile, the bottom-sheet anchor avoids the dialog re-centering that makes + * `AlertDialog` jump when its content height changes with the soft keyboard up + * (see issue #135). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AdaptiveModal( + onDismissRequest: () -> Unit, + confirmButton: @Composable () -> Unit, + modifier: Modifier = Modifier, + dismissButton: (@Composable () -> Unit)? = null, + title: (@Composable () -> Unit)? = null, + contentSpacing: Dp = 12.dp, + content: @Composable ColumnScope.() -> Unit, +) { + if (isMobilePlatform) { + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + ) + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + modifier = modifier, + ) { + Column( + modifier = Modifier + .padding(horizontal = 24.dp) + .padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(contentSpacing), + ) { + if (title != null) { + ProvideTextStyle(MaterialTheme.typography.headlineSmall) { + title() + } + } + content() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + if (dismissButton != null) { + dismissButton() + Spacer(Modifier.width(8.dp)) + } + confirmButton() + } + } + } + } else { + AlertDialog( + onDismissRequest = onDismissRequest, + title = title, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(contentSpacing), + content = content, + ) + }, + confirmButton = confirmButton, + dismissButton = dismissButton, + modifier = modifier, + ) + } +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/common/PrioritySelector.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/common/PrioritySelector.kt index c6abf195..970e37b2 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/common/PrioritySelector.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/common/PrioritySelector.kt @@ -1,7 +1,8 @@ package com.linroid.ketch.app.ui.common import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.LowPriority @@ -50,15 +51,17 @@ fun PriorityIcon( } } +@OptIn(ExperimentalLayoutApi::class) @Composable fun PrioritySelector( value: DownloadPriority, onValueChange: (DownloadPriority) -> Unit, modifier: Modifier = Modifier, ) { - Row( + FlowRow( modifier = modifier, horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), ) { DownloadPriority.entries.forEach { priority -> FilterChip( diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/common/ScheduleToggle.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/common/ScheduleToggle.kt index 07359544..3c66f555 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/common/ScheduleToggle.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/common/ScheduleToggle.kt @@ -1,7 +1,8 @@ package com.linroid.ketch.app.ui.common import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Schedule @@ -75,15 +76,17 @@ fun ScheduleIcon( } } +@OptIn(ExperimentalLayoutApi::class) @Composable fun ScheduleSelector( value: DownloadSchedule, onValueChange: (DownloadSchedule) -> Unit, modifier: Modifier = Modifier, ) { - Row( + FlowRow( modifier = modifier, horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), ) { scheduleOptions.forEach { option -> FilterChip( @@ -101,6 +104,7 @@ fun ScheduleSelector( } } +@OptIn(ExperimentalLayoutApi::class) @Composable fun SchedulePanel( task: DownloadTask, @@ -108,9 +112,10 @@ fun SchedulePanel( onScheduled: () -> Unit, modifier: Modifier = Modifier, ) { - Row( + FlowRow( modifier = modifier, horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), ) { scheduleOptions.forEach { option -> FilterChip( diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/dialog/AddDownloadDialog.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/dialog/AddDownloadDialog.kt index c7f50e90..d708deba 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/dialog/AddDownloadDialog.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/dialog/AddDownloadDialog.kt @@ -10,6 +10,7 @@ import androidx.compose.animation.togetherWith import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -26,7 +27,6 @@ import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.ErrorOutline import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.WarningAmber -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator @@ -59,6 +59,7 @@ import com.linroid.ketch.api.ResolvedSource import com.linroid.ketch.api.SourceFile import com.linroid.ketch.api.SpeedLimit import com.linroid.ketch.app.state.ResolveState +import com.linroid.ketch.app.ui.common.AdaptiveModal import com.linroid.ketch.app.ui.common.PriorityIcon import com.linroid.ketch.app.ui.common.PrioritySelector import com.linroid.ketch.app.ui.common.ScheduleIcon @@ -112,9 +113,7 @@ fun AddDownloadDialog( val selectedFileIds = remember { mutableSetOf().toMutableStateList() } - val urlFocusRequester = remember { - FocusRequester() - } + val urlFocusRequester = remember { FocusRequester() } val needsAuth = resolveState is ResolveState.Error && resolveState.cause is KetchError.AuthenticationFailed @@ -165,281 +164,255 @@ fun AddDownloadDialog( } } - AlertDialog( - onDismissRequest = { - onResetResolve() - onDismiss() - }, - title = { Text("Add download") }, - text = { - Column( - verticalArrangement = - Arrangement.spacedBy(12.dp) - ) { - LaunchedEffect(Unit) { - urlFocusRequester.requestFocus() + val onCancel = { + onResetResolve() + onDismiss() + } + + val hasMultipleFiles = resolved != null && + resolved.files.size > 1 + + val formContent: @Composable ColumnScope.() -> Unit = { + LaunchedEffect(Unit) { + urlFocusRequester.requestFocus() + } + OutlinedTextField( + value = url, + onValueChange = { + url = it + if (!fileNameEditedByUser) { + // Reset filename so resolve can fill it + fileName = "" } - OutlinedTextField( - value = url, - onValueChange = { - url = it - if (!fileNameEditedByUser) { - // Reset filename so resolve can fill it - fileName = "" - } - }, - modifier = Modifier.fillMaxWidth() - .focusRequester(urlFocusRequester), - label = { Text("URL") }, - singleLine = true, - placeholder = { - Text("URL, magnet link, or .torrent") - }, - isError = - resolveState is ResolveState.Error, - trailingIcon = { - when (resolveState) { - is ResolveState.Resolving -> { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp, - ) - } - is ResolveState.Resolved -> { - Icon( - Icons.Filled.CheckCircle, - contentDescription = "Resolved", - tint = - MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp), - ) - } - is ResolveState.Error -> { - IconButton( - onClick = { - if (url.isNotBlank()) { - val resolveUrl = buildResolveUrl() - lastResolvedSource = resolveUrl - onResolveUrl(resolveUrl) - } - } - ) { - Icon( - Icons.Filled.Refresh, - contentDescription = "Retry", - tint = MaterialTheme.colorScheme - .error, - modifier = Modifier.size(20.dp), - ) + }, + modifier = Modifier.fillMaxWidth() + .focusRequester(urlFocusRequester), + label = { Text("URL") }, + singleLine = true, + placeholder = { + Text("URL, magnet link, or .torrent") + }, + isError = resolveState is ResolveState.Error, + trailingIcon = { + when (resolveState) { + is ResolveState.Resolving -> { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + } + is ResolveState.Resolved -> { + Icon( + Icons.Filled.CheckCircle, + contentDescription = "Resolved", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + is ResolveState.Error -> { + IconButton( + onClick = { + if (url.isNotBlank()) { + val resolveUrl = buildResolveUrl() + lastResolvedSource = resolveUrl + onResolveUrl(resolveUrl) } } - is ResolveState.Idle -> {} + ) { + Icon( + Icons.Filled.Refresh, + contentDescription = "Retry", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp), + ) } - }, - supportingText = null - ) - - // Resolve result section - ResolveInfoSection(resolveState) - - // File selector for multi-file sources (e.g., torrent) - val hasFiles = resolved != null && - resolved.files.size > 1 - AnimatedVisibility( - visible = hasFiles, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut(), - ) { - if (resolved != null && resolved.files.size > 1) { - FileSelector( - files = resolved.files, - selectedIds = selectedFileIds, - onToggle = { id -> - if (id in selectedFileIds) { - selectedFileIds.remove(id) - } else { - selectedFileIds.add(id) - } - }, - onSelectAll = { - selectedFileIds.clear() - selectedFileIds.addAll( - resolved.files.map { it.id } - ) - }, - onDeselectAll = { - selectedFileIds.clear() - }, - ) } + is ResolveState.Idle -> {} } + }, + supportingText = null, + ) - // Credential fields shown on authentication failure - AnimatedVisibility( - visible = needsAuth, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut(), - ) { - CredentialFields( - username = username, - password = password, - onUsernameChange = { username = it }, - onPasswordChange = { password = it }, - onRetry = { - val resolveUrl = buildResolveUrl() - lastResolvedSource = resolveUrl - onResolveUrl(resolveUrl) - }, - ) - } + // Resolve result section + ResolveInfoSection(resolveState) - OutlinedTextField( - value = fileName, - onValueChange = { - fileName = it - fileNameEditedByUser = it.isNotBlank() - }, - modifier = Modifier.fillMaxWidth(), - label = { Text("Save as") }, - singleLine = true, - placeholder = { - if (resolved?.suggestedFileName != null) { - Text(resolved.suggestedFileName!!) + // File selector for multi-file sources (e.g., torrent) + AnimatedVisibility( + visible = hasMultipleFiles, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + if (resolved != null && resolved.files.size > 1) { + FileSelector( + files = resolved.files, + selectedIds = selectedFileIds, + onToggle = { id -> + if (id in selectedFileIds) { + selectedFileIds.remove(id) } else { - Text("Auto-detected from server") + selectedFileIds.add(id) } }, - supportingText = if (fileName.isBlank() && - url.isNotBlank() - ) { - { Text("Will be determined by server") } - } else { - null - } + onSelectAll = { + selectedFileIds.clear() + selectedFileIds.addAll( + resolved.files.map { it.id } + ) + }, + onDeselectAll = { + selectedFileIds.clear() + }, ) + } + } - // Toggle icon row - Row( - horizontalArrangement = - Arrangement.spacedBy(4.dp), - verticalAlignment = - Alignment.CenterVertically - ) { - SpeedLimitIcon( - active = - !selectedSpeed.isUnlimited, - selected = - expanded == DialogPanel.SpeedLimit, - onClick = { - expanded = if (expanded == - DialogPanel.SpeedLimit - ) { - DialogPanel.None - } else { - DialogPanel.SpeedLimit - } - } - ) - PriorityIcon( - active = selectedPriority != - DownloadPriority.NORMAL, - selected = - expanded == DialogPanel.Priority, - onClick = { - expanded = if (expanded == - DialogPanel.Priority - ) { - DialogPanel.None - } else { - DialogPanel.Priority - } - } - ) - ScheduleIcon( - selected = - expanded == DialogPanel.Schedule, - onClick = { - expanded = if (expanded == - DialogPanel.Schedule - ) { - DialogPanel.None - } else { - DialogPanel.Schedule - } - } - ) + // Credential fields shown on authentication failure + AnimatedVisibility( + visible = needsAuth, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + CredentialFields( + username = username, + password = password, + onUsernameChange = { username = it }, + onPasswordChange = { password = it }, + onRetry = { + val resolveUrl = buildResolveUrl() + lastResolvedSource = resolveUrl + onResolveUrl(resolveUrl) + }, + ) + } + + OutlinedTextField( + value = fileName, + onValueChange = { + fileName = it + fileNameEditedByUser = it.isNotBlank() + }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Save as") }, + singleLine = true, + placeholder = { + if (resolved?.suggestedFileName != null) { + Text(resolved.suggestedFileName!!) + } else { + Text("Auto-detected from server") } + }, + supportingText = if (fileName.isBlank() && + url.isNotBlank() + ) { + { Text("Will be determined by server") } + } else { + null + }, + ) - // Expanded panel - AnimatedContent( - targetState = expanded, - transitionSpec = { - expandVertically() togetherWith - shrinkVertically() + // Toggle icon row + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SpeedLimitIcon( + active = !selectedSpeed.isUnlimited, + selected = expanded == DialogPanel.SpeedLimit, + onClick = { + expanded = if (expanded == DialogPanel.SpeedLimit) { + DialogPanel.None + } else { + DialogPanel.SpeedLimit } - ) { panel -> - when (panel) { - DialogPanel.SpeedLimit -> - SpeedLimitSelector( - value = selectedSpeed, - onValueChange = { - selectedSpeed = it - } - ) - DialogPanel.Priority -> - PrioritySelector( - value = selectedPriority, - onValueChange = { - selectedPriority = it - } - ) - DialogPanel.Schedule -> - ScheduleSelector( - value = selectedSchedule, - onValueChange = { - selectedSchedule = it - } - ) - DialogPanel.None -> {} + }, + ) + PriorityIcon( + active = selectedPriority != DownloadPriority.NORMAL, + selected = expanded == DialogPanel.Priority, + onClick = { + expanded = if (expanded == DialogPanel.Priority) { + DialogPanel.None + } else { + DialogPanel.Priority } - } - } - }, - confirmButton = { - val hasMultipleFiles = resolved != null && - resolved.files.size > 1 - Button( + }, + ) + ScheduleIcon( + selected = expanded == DialogPanel.Schedule, onClick = { - val downloadUrl = buildResolveUrl() - if (downloadUrl.isNotEmpty()) { - val fileIds = if (hasMultipleFiles) { - selectedFileIds.toSet() - } else { - emptySet() - } - onDownload( - downloadUrl, fileName.trim(), - selectedSpeed, selectedPriority, - selectedSchedule, resolved, fileIds, - ) + expanded = if (expanded == DialogPanel.Schedule) { + DialogPanel.None + } else { + DialogPanel.Schedule } }, - enabled = url.isNotBlank() && - (!hasMultipleFiles || selectedFileIds.isNotEmpty()), - ) { - Text("Download") + ) + } + + // Expanded panel + AnimatedContent( + targetState = expanded, + transitionSpec = { + expandVertically() togetherWith shrinkVertically() + }, + ) { panel -> + when (panel) { + DialogPanel.SpeedLimit -> + SpeedLimitSelector( + value = selectedSpeed, + onValueChange = { selectedSpeed = it }, + ) + DialogPanel.Priority -> + PrioritySelector( + value = selectedPriority, + onValueChange = { selectedPriority = it }, + ) + DialogPanel.Schedule -> + ScheduleSelector( + value = selectedSchedule, + onValueChange = { selectedSchedule = it }, + ) + DialogPanel.None -> {} } - }, - dismissButton = { - TextButton( - onClick = { - onResetResolve() - onDismiss() + } + } + + val confirmAction: @Composable () -> Unit = { + Button( + onClick = { + val downloadUrl = buildResolveUrl() + if (downloadUrl.isNotEmpty()) { + val fileIds = if (hasMultipleFiles) { + selectedFileIds.toSet() + } else { + emptySet() + } + onDownload( + downloadUrl, fileName.trim(), + selectedSpeed, selectedPriority, + selectedSchedule, resolved, fileIds, + ) } - ) { - Text("Cancel") - } + }, + enabled = url.isNotBlank() && + (!hasMultipleFiles || selectedFileIds.isNotEmpty()), + ) { + Text("Download") } + } + + val cancelAction: @Composable () -> Unit = { + TextButton(onClick = onCancel) { + Text("Cancel") + } + } + + AdaptiveModal( + onDismissRequest = onCancel, + title = { Text("Add download") }, + confirmButton = confirmAction, + dismissButton = cancelAction, + content = formContent, ) } diff --git a/app/shared/src/iosMain/kotlin/com/linroid/ketch/app/platform/Platform.ios.kt b/app/shared/src/iosMain/kotlin/com/linroid/ketch/app/platform/Platform.ios.kt new file mode 100644 index 00000000..d9b44559 --- /dev/null +++ b/app/shared/src/iosMain/kotlin/com/linroid/ketch/app/platform/Platform.ios.kt @@ -0,0 +1,3 @@ +package com.linroid.ketch.app.platform + +actual val isMobilePlatform: Boolean = true diff --git a/app/shared/src/jvmMain/kotlin/com/linroid/ketch/app/platform/Platform.jvm.kt b/app/shared/src/jvmMain/kotlin/com/linroid/ketch/app/platform/Platform.jvm.kt new file mode 100644 index 00000000..410bcc27 --- /dev/null +++ b/app/shared/src/jvmMain/kotlin/com/linroid/ketch/app/platform/Platform.jvm.kt @@ -0,0 +1,3 @@ +package com.linroid.ketch.app.platform + +actual val isMobilePlatform: Boolean = false diff --git a/app/shared/src/wasmJsMain/kotlin/com/linroid/ketch/app/platform/Platform.wasmJs.kt b/app/shared/src/wasmJsMain/kotlin/com/linroid/ketch/app/platform/Platform.wasmJs.kt new file mode 100644 index 00000000..410bcc27 --- /dev/null +++ b/app/shared/src/wasmJsMain/kotlin/com/linroid/ketch/app/platform/Platform.wasmJs.kt @@ -0,0 +1,3 @@ +package com.linroid.ketch.app.platform + +actual val isMobilePlatform: Boolean = false