From beab4100edf2486932c76d189434cd7006222cf0 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Thu, 13 Aug 2026 13:18:56 +0200 Subject: [PATCH] feat(shared): refine saved devices experience --- .codex/skills/compose-skill/SKILL.md | 1 + localization/src/commands/validate.ts | 4 + localization/strings.json | 104 ++---- .../drawable/icon_fluent_device.xml | 5 + .../drawable/icon_lucide_device.xml | 7 + .../drawable/icon_material_device.xml | 5 + .../composeResources/values-de/strings.xml | 10 +- .../composeResources/values-es/strings.xml | 10 +- .../composeResources/values-fr/strings.xml | 10 +- .../composeResources/values-it/strings.xml | 10 +- .../composeResources/values-nl/strings.xml | 10 +- .../composeResources/values-pl/strings.xml | 10 +- .../composeResources/values-pt/strings.xml | 10 +- .../composeResources/values-ru/strings.xml | 10 +- .../composeResources/values/strings.xml | 10 +- .../saveddevices/SavedDeviceDetailsDrawer.kt | 214 +++++++++++ .../feature/saveddevices/SavedDeviceHub.kt | 337 ++++++------------ .../SavedDeviceTransferHistory.kt | 55 +-- .../saveddevices/SavedDevicesScreen.kt | 80 +++-- .../saveddevices/SavedDevicesViewModel.kt | 41 ++- .../com/vnidrop/app/ui/icons/PlatformIcons.kt | 1 + .../app/ui/navigation/AppDestination.kt | 2 +- .../saveddevices/SavedDevicesViewModelTest.kt | 50 +++ .../kotlin/com/vnidrop/app/support/Fakes.kt | 6 +- .../saveddevices/SavedDevicesScreenTest.kt | 26 +- shared/tools/import_platform_icons.py | 1 + 26 files changed, 572 insertions(+), 457 deletions(-) create mode 100644 shared/src/commonMain/composeResources/drawable/icon_fluent_device.xml create mode 100644 shared/src/commonMain/composeResources/drawable/icon_lucide_device.xml create mode 100644 shared/src/commonMain/composeResources/drawable/icon_material_device.xml create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDeviceDetailsDrawer.kt diff --git a/.codex/skills/compose-skill/SKILL.md b/.codex/skills/compose-skill/SKILL.md index 4931296..ffd32b1 100644 --- a/.codex/skills/compose-skill/SKILL.md +++ b/.codex/skills/compose-skill/SKILL.md @@ -110,6 +110,7 @@ Use one deep, session-scoped composition module for Invitation and Targeted crea Build quiet, intentional product interfaces. Establish hierarchy with typography, alignment, spacing, and native controls before adding containers or decoration. - Give each screen one clear primary task and scanning order. +- Use title-only headers for familiar, populated screens. Put explanatory copy in genuine empty/onboarding states or beside the specific control that needs clarification. - Use cards only when a real object or boundary needs containment. Prefer native lists, grouped rows, dividers, and whitespace for ordinary collections. - Use count badges only when the count changes a decision. Use icon tiles only when the icon is meaningful content or a native convention. - Keep accent color scarce. Let status, selection, or the primary action earn it. diff --git a/localization/src/commands/validate.ts b/localization/src/commands/validate.ts index 4f20ae8..389cef5 100644 --- a/localization/src/commands/validate.ts +++ b/localization/src/commands/validate.ts @@ -3,6 +3,7 @@ import { STRINGS_JSON } from "../config"; import type { StringsFile } from "../types"; const TOKEN = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g; +const PLATFORM_PLACEHOLDER = /%(?:\d+\$)?(?:l{0,2})?[@sdif]/; export async function validate() { const doc = JSON.parse(await Bun.file(STRINGS_JSON).text()) as StringsFile; @@ -33,6 +34,9 @@ export async function validate() { } for (const text of texts) { + if (argNames.size > 0 && PLATFORM_PLACEHOLDER.test(text)) { + errors.push(`${where}: uses a platform printf placeholder; use declared {name} placeholders in strings.json.`); + } for (const m of text.matchAll(TOKEN)) { if (!argNames.has(m[1]!)) { errors.push(`${where}: uses {${m[1]}} but no matching arg is declared.`); diff --git a/localization/strings.json b/localization/strings.json index f9958d5..63119b9 100644 --- a/localization/strings.json +++ b/localization/strings.json @@ -4843,21 +4843,6 @@ }, "targets": ["kmp"] }, - "saved_devices_devices_title": { - "context": "Saved devices screen: heading for mutually trusted saved devices.", - "translations": { - "en": "Your devices", - "fr": "Vos appareils", - "es": "Tus dispositivos", - "it": "I tuoi dispositivi", - "de": "Deine Geräte", - "pt": "Os seus dispositivos", - "pl": "Twoje urządzenia", - "nl": "Je apparaten", - "ru": "Ваши устройства" - }, - "targets": ["kmp"] - }, "saved_devices_transfers_title": { "context": "Saved devices screen: heading for durable targeted-transfer history.", "translations": { @@ -4892,15 +4877,15 @@ "context": "Targeted transfer card: incoming direction followed by the saved-device display name.", "args": [{"name": "device", "type": "string"}], "translations": { - "en": "From %1$s", - "fr": "De %1$s", - "es": "De %1$s", - "it": "Da %1$s", - "de": "Von %1$s", - "pt": "De %1$s", - "pl": "Od %1$s", - "nl": "Van %1$s", - "ru": "От %1$s" + "en": "From {device}", + "fr": "De {device}", + "es": "De {device}", + "it": "Da {device}", + "de": "Von {device}", + "pt": "De {device}", + "pl": "Od {device}", + "nl": "Van {device}", + "ru": "От {device}" }, "targets": ["kmp"] }, @@ -4908,15 +4893,15 @@ "context": "Targeted transfer card: outgoing direction followed by the saved-device display name.", "args": [{"name": "device", "type": "string"}], "translations": { - "en": "To %1$s", - "fr": "Vers %1$s", - "es": "A %1$s", - "it": "A %1$s", - "de": "An %1$s", - "pt": "Para %1$s", - "pl": "Do %1$s", - "nl": "Naar %1$s", - "ru": "Для %1$s" + "en": "To {device}", + "fr": "Vers {device}", + "es": "A {device}", + "it": "A {device}", + "de": "An {device}", + "pt": "Para {device}", + "pl": "Do {device}", + "nl": "Naar {device}", + "ru": "Для {device}" }, "targets": ["kmp"] }, @@ -4924,15 +4909,15 @@ "context": "Targeted transfer card metadata. First placeholder is file count, second is formatted total size.", "args": [{"name": "count", "type": "string"}, {"name": "size", "type": "string"}], "translations": { - "en": "%1$s files · %2$s", - "fr": "%1$s fichiers · %2$s", - "es": "%1$s archivos · %2$s", - "it": "%1$s file · %2$s", - "de": "%1$s Dateien · %2$s", - "pt": "%1$s ficheiros · %2$s", - "pl": "%1$s plików · %2$s", - "nl": "%1$s bestanden · %2$s", - "ru": "%1$s файлов · %2$s" + "en": "{count} files · {size}", + "fr": "{count} fichiers · {size}", + "es": "{count} archivos · {size}", + "it": "{count} file · {size}", + "de": "{count} Dateien · {size}", + "pt": "{count} ficheiros · {size}", + "pl": "{count} plików · {size}", + "nl": "{count} bestanden · {size}", + "ru": "{count} файлов · {size}" }, "targets": ["kmp"] }, @@ -4940,15 +4925,15 @@ "context": "Targeted transfer card progress. First placeholder is verified bytes, second is total size.", "args": [{"name": "verified", "type": "string"}, {"name": "total", "type": "string"}], "translations": { - "en": "%1$s of %2$s", - "fr": "%1$s sur %2$s", - "es": "%1$s de %2$s", - "it": "%1$s di %2$s", - "de": "%1$s von %2$s", - "pt": "%1$s de %2$s", - "pl": "%1$s z %2$s", - "nl": "%1$s van %2$s", - "ru": "%1$s из %2$s" + "en": "{verified} of {total}", + "fr": "{verified} sur {total}", + "es": "{verified} de {total}", + "it": "{verified} di {total}", + "de": "{verified} von {total}", + "pt": "{verified} de {total}", + "pl": "{verified} z {total}", + "nl": "{verified} van {total}", + "ru": "{verified} из {total}" }, "targets": ["kmp"] }, @@ -5058,23 +5043,6 @@ "kmp" ] }, - "saved_devices_description": { - "context": "Saved devices screen: short explanation below the title.", - "translations": { - "en": "Send directly to devices you trust, without sharing another invitation.", - "fr": "Envoyez directement aux appareils de confiance, sans partager une nouvelle invitation.", - "es": "Envía directamente a dispositivos de confianza sin compartir otra invitación.", - "it": "Invia direttamente ai dispositivi attendibili senza condividere un altro invito.", - "de": "Direkt an vertrauenswürdige Geräte senden, ohne eine neue Einladung zu teilen.", - "pt": "Envie diretamente para dispositivos de confiança sem partilhar outro convite.", - "pl": "Wysyłaj bezpośrednio do zaufanych urządzeń bez udostępniania kolejnego zaproszenia.", - "nl": "Stuur rechtstreeks naar vertrouwde apparaten zonder opnieuw een uitnodiging te delen.", - "ru": "Отправляйте напрямую доверенным устройствам без новой ссылки-приглашения." - }, - "targets": [ - "kmp" - ] - }, "saved_devices_endpoint": { "context": "Saved-device screen: secondary diagnostic endpoint identity. Placeholder is a shortened endpoint ID.", "translations": { diff --git a/shared/src/commonMain/composeResources/drawable/icon_fluent_device.xml b/shared/src/commonMain/composeResources/drawable/icon_fluent_device.xml new file mode 100644 index 0000000..d209553 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/icon_fluent_device.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/shared/src/commonMain/composeResources/drawable/icon_lucide_device.xml b/shared/src/commonMain/composeResources/drawable/icon_lucide_device.xml new file mode 100644 index 0000000..ca6a2b4 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/icon_lucide_device.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/shared/src/commonMain/composeResources/drawable/icon_material_device.xml b/shared/src/commonMain/composeResources/drawable/icon_material_device.xml new file mode 100644 index 0000000..f75f2f0 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/icon_material_device.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/shared/src/commonMain/composeResources/values-de/strings.xml b/shared/src/commonMain/composeResources/values-de/strings.xml index ff4a2d4..8c7c90a 100644 --- a/shared/src/commonMain/composeResources/values-de/strings.xml +++ b/shared/src/commonMain/composeResources/values-de/strings.xml @@ -320,13 +320,12 @@ Empfangen Ablehnen Erfordert Aufmerksamkeit - Deine Geräte Direkte Übertragungen Noch keine direkten Übertragungen. - Von %%1$s - An %%1$s - %%1$s Dateien · %%2$s - %%1$s von %%2$s + Von %1$s + An %1$s + %1$s Dateien · %2$s + %1$s von %2$s Empfangen Fortsetzen Abbrechen @@ -341,7 +340,6 @@ Remote-Name: %1$s %1$s blockieren und künftige Übertragungen gespeicherter Geräte ablehnen? Gerät blockieren? - Direkt an vertrauenswürdige Geräte senden, ohne eine neue Einladung zu teilen. Geräte-ID: %1$s Schließe eine Übertragung ab und speichere anschließend das andere Gerät. Keine gespeicherten Geräte diff --git a/shared/src/commonMain/composeResources/values-es/strings.xml b/shared/src/commonMain/composeResources/values-es/strings.xml index da2f1ac..26091e3 100644 --- a/shared/src/commonMain/composeResources/values-es/strings.xml +++ b/shared/src/commonMain/composeResources/values-es/strings.xml @@ -320,13 +320,12 @@ Recibir Rechazar Requiere atención - Tus dispositivos Transferencias directas Aún no hay transferencias directas. - De %%1$s - A %%1$s - %%1$s archivos · %%2$s - %%1$s de %%2$s + De %1$s + A %1$s + %1$s archivos · %2$s + %1$s de %2$s Recibir Reanudar Cancelar @@ -341,7 +340,6 @@ Nombre remoto: %1$s ¿Bloquear a %1$s y rechazar futuros envíos de dispositivos guardados? ¿Bloquear dispositivo? - Envía directamente a dispositivos de confianza sin compartir otra invitación. ID del dispositivo: %1$s Finaliza una transferencia y elige recordar el otro dispositivo. No hay dispositivos guardados diff --git a/shared/src/commonMain/composeResources/values-fr/strings.xml b/shared/src/commonMain/composeResources/values-fr/strings.xml index 4447b34..86308d6 100644 --- a/shared/src/commonMain/composeResources/values-fr/strings.xml +++ b/shared/src/commonMain/composeResources/values-fr/strings.xml @@ -320,13 +320,12 @@ Recevoir Refuser Nécessite votre attention - Vos appareils Transferts directs Aucun transfert direct pour le moment. - De %%1$s - Vers %%1$s - %%1$s fichiers · %%2$s - %%1$s sur %%2$s + De %1$s + Vers %1$s + %1$s fichiers · %2$s + %1$s sur %2$s Recevoir Reprendre Annuler @@ -341,7 +340,6 @@ Nom distant : %1$s Bloquer %1$s et refuser les futurs transferts d’appareil enregistré ? Bloquer l’appareil ? - Envoyez directement aux appareils de confiance, sans partager une nouvelle invitation. ID de l’appareil : %1$s Terminez un transfert, puis choisissez de mémoriser l’autre appareil. Aucun appareil enregistré diff --git a/shared/src/commonMain/composeResources/values-it/strings.xml b/shared/src/commonMain/composeResources/values-it/strings.xml index a094328..eff673a 100644 --- a/shared/src/commonMain/composeResources/values-it/strings.xml +++ b/shared/src/commonMain/composeResources/values-it/strings.xml @@ -320,13 +320,12 @@ Ricevi Rifiuta Richiede attenzione - I tuoi dispositivi Trasferimenti diretti Nessun trasferimento diretto. - Da %%1$s - A %%1$s - %%1$s file · %%2$s - %%1$s di %%2$s + Da %1$s + A %1$s + %1$s file · %2$s + %1$s di %2$s Ricevi Riprendi Annulla @@ -341,7 +340,6 @@ Nome remoto: %1$s Bloccare %1$s e rifiutare i futuri trasferimenti da dispositivi salvati? Bloccare il dispositivo? - Invia direttamente ai dispositivi attendibili senza condividere un altro invito. ID dispositivo: %1$s Completa un trasferimento, quindi scegli di ricordare l’altro dispositivo. Nessun dispositivo salvato diff --git a/shared/src/commonMain/composeResources/values-nl/strings.xml b/shared/src/commonMain/composeResources/values-nl/strings.xml index 90d76c4..108cfd1 100644 --- a/shared/src/commonMain/composeResources/values-nl/strings.xml +++ b/shared/src/commonMain/composeResources/values-nl/strings.xml @@ -320,13 +320,12 @@ Ontvangen Weigeren Aandacht vereist - Je apparaten Directe overdrachten Nog geen directe overdrachten. - Van %%1$s - Naar %%1$s - %%1$s bestanden · %%2$s - %%1$s van %%2$s + Van %1$s + Naar %1$s + %1$s bestanden · %2$s + %1$s van %2$s Ontvangen Hervatten Annuleren @@ -341,7 +340,6 @@ Externe naam: %1$s %1$s blokkeren en toekomstige overdrachten van opgeslagen apparaten weigeren? Apparaat blokkeren? - Stuur rechtstreeks naar vertrouwde apparaten zonder opnieuw een uitnodiging te delen. Apparaat-ID: %1$s Voltooi een overdracht en kies daarna om het andere apparaat te onthouden. Geen opgeslagen apparaten diff --git a/shared/src/commonMain/composeResources/values-pl/strings.xml b/shared/src/commonMain/composeResources/values-pl/strings.xml index 1568d37..5e2b5ac 100644 --- a/shared/src/commonMain/composeResources/values-pl/strings.xml +++ b/shared/src/commonMain/composeResources/values-pl/strings.xml @@ -320,13 +320,12 @@ Odbierz Odrzuć Wymaga uwagi - Twoje urządzenia Transfery bezpośrednie Brak transferów bezpośrednich. - Od %%1$s - Do %%1$s - %%1$s plików · %%2$s - %%1$s z %%2$s + Od %1$s + Do %1$s + %1$s plików · %2$s + %1$s z %2$s Odbierz Wznów Anuluj @@ -341,7 +340,6 @@ Nazwa zdalna: %1$s Zablokować %1$s i odrzucać przyszłe transfery z zapisanych urządzeń? Zablokować urządzenie? - Wysyłaj bezpośrednio do zaufanych urządzeń bez udostępniania kolejnego zaproszenia. ID urządzenia: %1$s Ukończ transfer, a następnie zapisz drugie urządzenie. Brak zapisanych urządzeń diff --git a/shared/src/commonMain/composeResources/values-pt/strings.xml b/shared/src/commonMain/composeResources/values-pt/strings.xml index 3cb6560..4bc978c 100644 --- a/shared/src/commonMain/composeResources/values-pt/strings.xml +++ b/shared/src/commonMain/composeResources/values-pt/strings.xml @@ -320,13 +320,12 @@ Receber Recusar Requer atenção - Os seus dispositivos Transferências diretas Ainda não existem transferências diretas. - De %%1$s - Para %%1$s - %%1$s ficheiros · %%2$s - %%1$s de %%2$s + De %1$s + Para %1$s + %1$s ficheiros · %2$s + %1$s de %2$s Receber Retomar Cancelar @@ -341,7 +340,6 @@ Nome remoto: %1$s Bloquear %1$s e rejeitar futuras transferências de dispositivos guardados? Bloquear dispositivo? - Envie diretamente para dispositivos de confiança sem partilhar outro convite. ID do dispositivo: %1$s Conclua uma transferência e escolha guardar o outro dispositivo. Nenhum dispositivo guardado diff --git a/shared/src/commonMain/composeResources/values-ru/strings.xml b/shared/src/commonMain/composeResources/values-ru/strings.xml index 988a7ec..e46d893 100644 --- a/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -320,13 +320,12 @@ Получить Отклонить Требует внимания - Ваши устройства Прямые передачи Прямых передач пока нет. - От %%1$s - Для %%1$s - %%1$s файлов · %%2$s - %%1$s из %%2$s + От %1$s + Для %1$s + %1$s файлов · %2$s + %1$s из %2$s Получить Продолжить Отменить @@ -341,7 +340,6 @@ Имя устройства: %1$s Заблокировать %1$s и отклонять будущие передачи с сохранённых устройств? Заблокировать устройство? - Отправляйте напрямую доверенным устройствам без новой ссылки-приглашения. ID устройства: %1$s Завершите передачу, затем сохраните другое устройство. Нет сохранённых устройств diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index b9325a9..5298f53 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -320,13 +320,12 @@ Receive Decline Needs attention - Your devices Direct transfers No direct transfers yet. - From %%1$s - To %%1$s - %%1$s files · %%2$s - %%1$s of %%2$s + From %1$s + To %1$s + %1$s files · %2$s + %1$s of %2$s Receive Resume Cancel @@ -341,7 +340,6 @@ Remote name: %1$s Block %1$s and reject future saved-device transfers? Block device? - Send directly to devices you trust, without sharing another invitation. Device ID: %1$s Finish a transfer, then choose to remember the other device. No saved devices diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDeviceDetailsDrawer.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDeviceDetailsDrawer.kt new file mode 100644 index 0000000..40c6e29 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDeviceDetailsDrawer.kt @@ -0,0 +1,214 @@ +package com.vnidrop.app.feature.saveddevices + +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.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vnidrop.app.core.SavedDeviceModel +import com.vnidrop.app.ui.components.AdaptiveDrawer +import com.vnidrop.app.ui.components.PrimaryButton +import com.vnidrop.app.ui.icons.AppIcon +import com.vnidrop.app.ui.icons.PlatformIcon +import com.vnidrop.app.ui.state.WindowClass +import com.vnidrop.app.ui.theme.LocalVniDropColors +import org.jetbrains.compose.resources.stringResource +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.button_cancel +import vnidrop.shared.generated.resources.saved_devices_authenticated_name +import vnidrop.shared.generated.resources.saved_devices_block_action +import vnidrop.shared.generated.resources.saved_devices_block_confirm_body +import vnidrop.shared.generated.resources.saved_devices_block_confirm_title +import vnidrop.shared.generated.resources.saved_devices_endpoint +import vnidrop.shared.generated.resources.saved_devices_forget_action +import vnidrop.shared.generated.resources.saved_devices_forget_confirm_body +import vnidrop.shared.generated.resources.saved_devices_forget_confirm_title +import vnidrop.shared.generated.resources.saved_devices_label_action +import vnidrop.shared.generated.resources.saved_devices_more_actions +import vnidrop.shared.generated.resources.saved_devices_send_action +import vnidrop.shared.generated.resources.saved_devices_transfer_empty +import vnidrop.shared.generated.resources.saved_devices_transfers_title + +private enum class DeviceDestructiveAction { Forget, Block } + +@Composable +internal fun SavedDeviceDetailsDrawer( + device: SavedDeviceModel, + transfers: List, + busy: Boolean, + busyTransferIds: Set, + windowClass: WindowClass, + onDismiss: () -> Unit, + onSend: () -> Unit, + onOpenLabel: () -> Unit, + onForget: () -> Unit, + onBlock: () -> Unit, + onTransferAction: (String, SavedDeviceTransferAction) -> Unit, +) { + var menuExpanded by remember(device.endpointId) { mutableStateOf(false) } + var pendingAction by remember(device.endpointId) { mutableStateOf(null) } + val colors = LocalVniDropColors.current + val title = device.displayName() + + AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onDismiss) { + LazyColumn( + modifier = Modifier.fillMaxWidth().heightIn(min = 320.dp, max = 720.dp), + contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 28.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + item(key = "device-header") { + Row(Modifier.fillMaxWidth().padding(end = 44.dp), verticalAlignment = Alignment.CenterVertically) { + PlatformIcon(AppIcon.Device, contentDescription = null, modifier = Modifier.size(30.dp), tint = colors.foregroundLight) + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.semantics { heading() }, + ) + device.remoteDisplayName + ?.takeIf { device.localLabel?.isNotBlank() == true && it.isNotBlank() } + ?.let { + Text( + stringResource(Res.string.saved_devices_authenticated_name, it), + style = MaterialTheme.typography.bodySmall, + color = colors.foregroundLight, + ) + } + } + if (busy) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + } + } + item(key = "device-identity") { + Text( + stringResource(Res.string.saved_devices_endpoint, shortDeviceEndpoint(device.endpointId)), + style = MaterialTheme.typography.bodySmall, + color = colors.foregroundLighter, + ) + } + item(key = "send") { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + PrimaryButton( + stringResource(Res.string.saved_devices_send_action), + onClick = onSend, + modifier = Modifier.weight(1f), + enabled = !busy, + leadingIcon = { PlatformIcon(AppIcon.Send, contentDescription = null, modifier = Modifier.size(18.dp)) }, + ) + Spacer(Modifier.width(8.dp)) + Box { + IconButton(onClick = { menuExpanded = true }, enabled = !busy) { + PlatformIcon( + AppIcon.MoreVertical, + contentDescription = stringResource(Res.string.saved_devices_more_actions, title), + ) + } + DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) { + DropdownMenuItem( + text = { Text(stringResource(Res.string.saved_devices_label_action)) }, + onClick = { menuExpanded = false; onOpenLabel() }, + leadingIcon = { PlatformIcon(AppIcon.User, contentDescription = null) }, + ) + DropdownMenuItem( + text = { Text(stringResource(Res.string.saved_devices_forget_action)) }, + onClick = { menuExpanded = false; pendingAction = DeviceDestructiveAction.Forget }, + leadingIcon = { PlatformIcon(AppIcon.UserOff, contentDescription = null) }, + ) + DropdownMenuItem( + text = { Text(stringResource(Res.string.saved_devices_block_action)) }, + onClick = { menuExpanded = false; pendingAction = DeviceDestructiveAction.Block }, + leadingIcon = { PlatformIcon(AppIcon.Lock, contentDescription = null) }, + ) + } + } + } + } + item(key = "transfer-heading") { + Text( + stringResource(Res.string.saved_devices_transfers_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 6.dp).semantics { heading() }, + ) + } + if (transfers.isEmpty()) { + item(key = "transfer-empty") { + Text( + stringResource(Res.string.saved_devices_transfer_empty), + style = MaterialTheme.typography.bodyMedium, + color = colors.foregroundLight, + ) + } + } else { + targetedTransferItems( + transfers = transfers, + busyTransferIds = busyTransferIds, + onAction = onTransferAction, + showHeading = false, + ) + } + } + } + + pendingAction?.let { action -> + val isBlock = action == DeviceDestructiveAction.Block + AlertDialog( + onDismissRequest = { pendingAction = null }, + title = { + Text(stringResource(if (isBlock) Res.string.saved_devices_block_confirm_title else Res.string.saved_devices_forget_confirm_title)) + }, + text = { + Text( + stringResource( + if (isBlock) Res.string.saved_devices_block_confirm_body else Res.string.saved_devices_forget_confirm_body, + title, + ), + ) + }, + confirmButton = { + TextButton( + onClick = { + pendingAction = null + onDismiss() + if (isBlock) onBlock() else onForget() + }, + ) { + Text(stringResource(if (isBlock) Res.string.saved_devices_block_action else Res.string.saved_devices_forget_action)) + } + }, + dismissButton = { + TextButton(onClick = { pendingAction = null }) { Text(stringResource(Res.string.button_cancel)) } + }, + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDeviceHub.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDeviceHub.kt index a2f26c3..8ade3e1 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDeviceHub.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDeviceHub.kt @@ -1,5 +1,6 @@ package com.vnidrop.app.feature.saveddevices +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -7,7 +8,6 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -16,21 +16,13 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag @@ -52,7 +44,6 @@ import com.vnidrop.app.ui.icons.PlatformIcon import com.vnidrop.app.ui.theme.LocalVniDropColors import org.jetbrains.compose.resources.stringResource import vnidrop.shared.generated.resources.Res -import vnidrop.shared.generated.resources.button_cancel import vnidrop.shared.generated.resources.button_retry import vnidrop.shared.generated.resources.offer_accept import vnidrop.shared.generated.resources.offer_body @@ -60,28 +51,14 @@ import vnidrop.shared.generated.resources.offer_decline import vnidrop.shared.generated.resources.offer_title import vnidrop.shared.generated.resources.saved_devices_accept_pairing_action import vnidrop.shared.generated.resources.saved_devices_attention_title -import vnidrop.shared.generated.resources.saved_devices_authenticated_name -import vnidrop.shared.generated.resources.saved_devices_block_action -import vnidrop.shared.generated.resources.saved_devices_block_confirm_body -import vnidrop.shared.generated.resources.saved_devices_block_confirm_title import vnidrop.shared.generated.resources.saved_devices_decline_action -import vnidrop.shared.generated.resources.saved_devices_devices_title import vnidrop.shared.generated.resources.saved_devices_eligibility_title import vnidrop.shared.generated.resources.saved_devices_empty import vnidrop.shared.generated.resources.saved_devices_empty_title -import vnidrop.shared.generated.resources.saved_devices_endpoint -import vnidrop.shared.generated.resources.saved_devices_forget_action -import vnidrop.shared.generated.resources.saved_devices_forget_confirm_body -import vnidrop.shared.generated.resources.saved_devices_forget_confirm_title -import vnidrop.shared.generated.resources.saved_devices_label_action import vnidrop.shared.generated.resources.saved_devices_load_failed -import vnidrop.shared.generated.resources.saved_devices_more_actions import vnidrop.shared.generated.resources.saved_devices_pending_incoming import vnidrop.shared.generated.resources.saved_devices_pending_outgoing import vnidrop.shared.generated.resources.saved_devices_remember_action -import vnidrop.shared.generated.resources.saved_devices_send_action -import vnidrop.shared.generated.resources.saved_devices_transfer_empty -import vnidrop.shared.generated.resources.saved_devices_transfers_title import vnidrop.shared.generated.resources.saved_devices_unnamed @Composable @@ -94,20 +71,75 @@ internal fun CompactSavedDevicesHub( onDeclineIncoming: (String) -> Unit, onAcceptOffer: (String) -> Unit, onDeclineOffer: (String) -> Unit, - onSend: (String) -> Unit, - onOpenLabel: (String) -> Unit, - onForget: (String) -> Unit, - onBlock: (String) -> Unit, - onTransferAction: (String, SavedDeviceTransferAction) -> Unit, + onOpenDevice: (String) -> Unit, +) { + SavedDevicesHub( + state = state, + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 28.dp), + onRetry = onRetry, + onRememberEligible = onRememberEligible, + onDeclineEligible = onDeclineEligible, + onAcceptIncoming = onAcceptIncoming, + onDeclineIncoming = onDeclineIncoming, + onAcceptOffer = onAcceptOffer, + onDeclineOffer = onDeclineOffer, + onOpenDevice = onOpenDevice, + ) +} + +@Composable +internal fun DesktopSavedDevicesHub( + state: SavedDevicesState, + onRetry: () -> Unit, + onRememberEligible: (String) -> Unit, + onDeclineEligible: (String) -> Unit, + onAcceptIncoming: (String) -> Unit, + onDeclineIncoming: (String) -> Unit, + onAcceptOffer: (String) -> Unit, + onDeclineOffer: (String) -> Unit, + onOpenDevice: (String) -> Unit, +) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + SavedDevicesHub( + state = state, + modifier = Modifier.widthIn(max = 720.dp), + contentPadding = PaddingValues(start = 24.dp, end = 24.dp, bottom = 32.dp), + onRetry = onRetry, + onRememberEligible = onRememberEligible, + onDeclineEligible = onDeclineEligible, + onAcceptIncoming = onAcceptIncoming, + onDeclineIncoming = onDeclineIncoming, + onAcceptOffer = onAcceptOffer, + onDeclineOffer = onDeclineOffer, + onOpenDevice = onOpenDevice, + ) + } +} + +@Composable +private fun SavedDevicesHub( + state: SavedDevicesState, + contentPadding: PaddingValues, + onRetry: () -> Unit, + onRememberEligible: (String) -> Unit, + onDeclineEligible: (String) -> Unit, + onAcceptIncoming: (String) -> Unit, + onDeclineIncoming: (String) -> Unit, + onAcceptOffer: (String) -> Unit, + onDeclineOffer: (String) -> Unit, + onOpenDevice: (String) -> Unit, + modifier: Modifier = Modifier, ) { val hasAttention = state.attentionCount > 0 - val isEmpty = !hasAttention && state.savedDevices.isEmpty() && state.targetedTransfers.isEmpty() + val showEmptyState = !hasAttention && state.savedDevices.isEmpty() LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 28.dp), + modifier = modifier.fillMaxSize(), + contentPadding = contentPadding, verticalArrangement = Arrangement.spacedBy(20.dp), ) { - if (state.isLoading) item(key = "loading") { androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth()) } + if (state.isLoading) { + item(key = "loading") { androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth()) } + } if (state.loadFailed) item(key = "load-failed") { InlineLoadFailure(onRetry) } if (hasAttention) { item(key = "attention") { @@ -123,93 +155,10 @@ internal fun CompactSavedDevicesHub( } } if (state.savedDevices.isNotEmpty()) { - item(key = "saved-devices") { - SavedDeviceSection(state, onSend, onOpenLabel, onForget, onBlock) - } + item(key = "saved-devices") { SavedDeviceSection(state, onOpenDevice) } } - if (state.targetedTransfers.isNotEmpty()) { - targetedTransferItems(state.targetedTransfers, state.busyTransferIds, onTransferAction) - } - if (isEmpty) item(key = "empty") { SavedDevicesEmptyState(Modifier.fillParentMaxHeight(0.72f)) } - } -} - -@Composable -internal fun DesktopSavedDevicesHub( - state: SavedDevicesState, - onRetry: () -> Unit, - onRememberEligible: (String) -> Unit, - onDeclineEligible: (String) -> Unit, - onAcceptIncoming: (String) -> Unit, - onDeclineIncoming: (String) -> Unit, - onAcceptOffer: (String) -> Unit, - onDeclineOffer: (String) -> Unit, - onSend: (String) -> Unit, - onOpenLabel: (String) -> Unit, - onForget: (String) -> Unit, - onBlock: (String) -> Unit, - onTransferAction: (String, SavedDeviceTransferAction) -> Unit, -) { - val hasAttention = state.attentionCount > 0 - val isEmpty = !hasAttention && state.savedDevices.isEmpty() && state.targetedTransfers.isEmpty() - if (isEmpty && !state.isLoading && !state.loadFailed) { - Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { - SavedDevicesEmptyState(Modifier.widthIn(max = 440.dp)) - } - return - } - Row( - modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp), - horizontalArrangement = Arrangement.spacedBy(32.dp), - ) { - LazyColumn( - modifier = Modifier.weight(0.9f).fillMaxHeight(), - verticalArrangement = Arrangement.spacedBy(20.dp), - contentPadding = PaddingValues(bottom = 24.dp), - ) { - if (state.isLoading) item(key = "loading") { androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth()) } - if (state.loadFailed) item(key = "load-failed") { InlineLoadFailure(onRetry) } - if (hasAttention) { - item(key = "attention") { - AttentionSection( - state, - onRememberEligible, - onDeclineEligible, - onAcceptIncoming, - onDeclineIncoming, - onAcceptOffer, - onDeclineOffer, - ) - } - } - if (state.savedDevices.isNotEmpty()) { - item(key = "saved-devices") { - SavedDeviceSection(state, onSend, onOpenLabel, onForget, onBlock) - } - } - } - LazyColumn( - modifier = Modifier.weight(1.1f).fillMaxHeight(), - verticalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(bottom = 24.dp), - ) { - if (state.targetedTransfers.isNotEmpty()) { - targetedTransferItems( - state.targetedTransfers, - state.busyTransferIds, - onTransferAction, - presentation = TargetedTransferPresentation.DesktopRow, - ) - } else { - item(key = "targeted-transfer-title") { SectionTitle(stringResource(Res.string.saved_devices_transfers_title)) } - item(key = "targeted-transfer-empty") { - Text( - stringResource(Res.string.saved_devices_transfer_empty), - style = MaterialTheme.typography.bodyMedium, - color = LocalVniDropColors.current.foregroundLight, - ) - } - } + if (showEmptyState) { + item(key = "empty") { SavedDevicesEmptyState(Modifier.fillParentMaxHeight(0.72f)) } } } } @@ -229,10 +178,7 @@ private fun AttentionSection( ) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { SectionTitle(stringResource(Res.string.saved_devices_attention_title)) - Surface( - shape = RoundedCornerShape(12.dp), - color = LocalVniDropColors.current.backgroundSurface200, - ) { + Surface(shape = RoundedCornerShape(12.dp), color = LocalVniDropColors.current.backgroundSurface200) { Column { var hasPrevious = false state.targetedOffers.pending.forEach { offer -> @@ -285,7 +231,6 @@ private fun TargetedOfferRow( icon = AppIcon.Download, title = stringResource(Res.string.offer_title), body = stringResource(Res.string.offer_body, deviceName, offer.transferName), - endpointId = offer.senderEndpointId, busy = busy, ) { PrimaryButton(stringResource(Res.string.offer_accept), onAccept, enabled = !busy) @@ -301,10 +246,9 @@ private fun EligibilityRow( onDecline: () -> Unit, ) { DecisionRow( - icon = AppIcon.Shield, + icon = AppIcon.Device, title = eligibility.remoteDisplayName?.takeIf(String::isNotBlank) ?: stringResource(Res.string.saved_devices_unnamed), body = stringResource(Res.string.saved_devices_eligibility_title), - endpointId = eligibility.peerEndpointId, busy = busy, ) { PrimaryButton(stringResource(Res.string.saved_devices_remember_action), onRemember, enabled = !busy) @@ -321,7 +265,7 @@ private fun PendingPairingRow( onDecline: () -> Unit, ) { DecisionRow( - icon = AppIcon.Shield, + icon = AppIcon.Device, title = remoteDisplayName?.takeIf(String::isNotBlank) ?: stringResource(Res.string.saved_devices_unnamed), body = stringResource( if (relationship.state == DeviceRelationshipStateModel.PendingIncoming) { @@ -330,7 +274,6 @@ private fun PendingPairingRow( Res.string.saved_devices_pending_outgoing }, ), - endpointId = relationship.remoteEndpointId, busy = busy, actions = if (relationship.state == DeviceRelationshipStateModel.PendingIncoming) { { @@ -348,18 +291,16 @@ private fun DecisionRow( icon: AppIcon, title: String, body: String, - endpointId: String, busy: Boolean, actions: (@Composable RowScope.() -> Unit)?, ) { val colors = LocalVniDropColors.current Column(Modifier.padding(horizontal = 16.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - PlatformIcon(icon, contentDescription = null, tint = colors.brandLink, modifier = Modifier.padding(top = 2.dp).size(22.dp)) + PlatformIcon(icon, contentDescription = null, tint = colors.foregroundLight, modifier = Modifier.padding(top = 2.dp).size(22.dp)) Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) Text(body, style = MaterialTheme.typography.bodyMedium, color = colors.foregroundLight) - DiagnosticEndpoint(endpointId) } if (busy) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) } @@ -368,108 +309,43 @@ private fun DecisionRow( } @Composable -private fun SavedDeviceSection( - state: SavedDevicesState, - onSend: (String) -> Unit, - onOpenLabel: (String) -> Unit, - onForget: (String) -> Unit, - onBlock: (String) -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - SectionTitle(stringResource(Res.string.saved_devices_devices_title)) - Surface(shape = RoundedCornerShape(12.dp), color = LocalVniDropColors.current.backgroundSurface200) { - Column { - state.savedDevices.forEachIndexed { index, device -> - if (index > 0) GroupDivider() - SavedDeviceRow( - device = device, - busy = device.endpointId in state.busyPeerIds, - onSend = { onSend(device.endpointId) }, - onLabel = { onOpenLabel(device.endpointId) }, - onForget = { onForget(device.endpointId) }, - onBlock = { onBlock(device.endpointId) }, - ) - } - } +private fun SavedDeviceSection(state: SavedDevicesState, onOpenDevice: (String) -> Unit) { + Column { + state.savedDevices.forEachIndexed { index, device -> + if (index > 0) GroupDivider() + SavedDeviceRow( + device = device, + busy = device.endpointId in state.busyPeerIds, + onOpen = { onOpenDevice(device.endpointId) }, + ) } } } -private enum class DeviceDestructiveAction { Forget, Block } - @Composable -private fun SavedDeviceRow( - device: SavedDeviceModel, - busy: Boolean, - onSend: () -> Unit, - onLabel: () -> Unit, - onForget: () -> Unit, - onBlock: () -> Unit, -) { +private fun SavedDeviceRow(device: SavedDeviceModel, busy: Boolean, onOpen: () -> Unit) { val colors = LocalVniDropColors.current - val title = device.displayName() - var menuExpanded by remember(device.endpointId) { mutableStateOf(false) } - var pendingAction by remember(device.endpointId) { mutableStateOf(null) } Row( - modifier = Modifier.fillMaxWidth().testTag("saved-device-${device.endpointId}").padding(start = 16.dp, end = 6.dp, top = 12.dp, bottom = 12.dp), + modifier = Modifier + .fillMaxWidth() + .testTag("saved-device-${device.endpointId}") + .clickable(onClick = onOpen) + .padding(horizontal = 4.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, ) { - PlatformIcon(AppIcon.ShieldCheck, contentDescription = null, tint = colors.foregroundLight, modifier = Modifier.size(24.dp)) + PlatformIcon(AppIcon.Device, contentDescription = null, tint = colors.foregroundLight, modifier = Modifier.size(24.dp)) Spacer(Modifier.width(14.dp)) - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) - device.remoteDisplayName - ?.takeIf { device.localLabel?.isNotBlank() == true && it.isNotBlank() } - ?.let { Text(stringResource(Res.string.saved_devices_authenticated_name, it), style = MaterialTheme.typography.bodySmall, color = colors.foregroundLight) } - DiagnosticEndpoint(device.endpointId) + Column(Modifier.weight(1f)) { + Text( + device.displayName(), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } - if (busy) { - CircularProgressIndicator(Modifier.padding(horizontal = 14.dp).size(20.dp), strokeWidth = 2.dp) - } else { - val sendLabel = stringResource(Res.string.saved_devices_send_action) - IconButton(onClick = onSend) { - PlatformIcon(AppIcon.Send, contentDescription = sendLabel) - } - Box { - val moreLabel = stringResource(Res.string.saved_devices_more_actions, title) - IconButton(onClick = { menuExpanded = true }) { - PlatformIcon(AppIcon.MoreVertical, contentDescription = moreLabel) - } - DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) { - DropdownMenuItem( - text = { Text(stringResource(Res.string.saved_devices_label_action)) }, - onClick = { menuExpanded = false; onLabel() }, - leadingIcon = { PlatformIcon(AppIcon.User, contentDescription = null) }, - ) - DropdownMenuItem( - text = { Text(stringResource(Res.string.saved_devices_forget_action)) }, - onClick = { menuExpanded = false; pendingAction = DeviceDestructiveAction.Forget }, - leadingIcon = { PlatformIcon(AppIcon.UserOff, contentDescription = null) }, - ) - DropdownMenuItem( - text = { Text(stringResource(Res.string.saved_devices_block_action)) }, - onClick = { menuExpanded = false; pendingAction = DeviceDestructiveAction.Block }, - leadingIcon = { PlatformIcon(AppIcon.Lock, contentDescription = null) }, - ) - } - } - } - } - pendingAction?.let { action -> - val isBlock = action == DeviceDestructiveAction.Block - AlertDialog( - onDismissRequest = { pendingAction = null }, - title = { Text(stringResource(if (isBlock) Res.string.saved_devices_block_confirm_title else Res.string.saved_devices_forget_confirm_title)) }, - text = { - Text(stringResource(if (isBlock) Res.string.saved_devices_block_confirm_body else Res.string.saved_devices_forget_confirm_body, title)) - }, - confirmButton = { - TextButton(onClick = { pendingAction = null; if (isBlock) onBlock() else onForget() }) { - Text(stringResource(if (isBlock) Res.string.saved_devices_block_action else Res.string.saved_devices_forget_action)) - } - }, - dismissButton = { TextButton(onClick = { pendingAction = null }) { Text(stringResource(Res.string.button_cancel)) } }, - ) + if (busy) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + else PlatformIcon(AppIcon.ChevronRight, contentDescription = null, tint = colors.foregroundLighter) } } @@ -491,17 +367,6 @@ private fun GroupDivider() { ) } -@Composable -private fun DiagnosticEndpoint(endpointId: String) { - Text( - stringResource(Res.string.saved_devices_endpoint, shortEndpoint(endpointId)), - style = MaterialTheme.typography.bodySmall, - color = LocalVniDropColors.current.foregroundLighter, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) -} - @Composable private fun SavedDevicesEmptyState(modifier: Modifier = Modifier) { val colors = LocalVniDropColors.current @@ -511,7 +376,7 @@ private fun SavedDevicesEmptyState(modifier: Modifier = Modifier) { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp), ) { - PlatformIcon(AppIcon.ShieldCheck, contentDescription = null, tint = colors.foregroundLighter, modifier = Modifier.size(36.dp)) + PlatformIcon(AppIcon.Device, contentDescription = null, tint = colors.foregroundLighter, modifier = Modifier.size(36.dp)) Text(stringResource(Res.string.saved_devices_empty_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) Text( stringResource(Res.string.saved_devices_empty), @@ -536,9 +401,9 @@ private fun InlineLoadFailure(onRetry: () -> Unit) { } @Composable -private fun SavedDeviceModel.displayName(): String = localLabel?.takeIf(String::isNotBlank) +internal fun SavedDeviceModel.displayName(): String = localLabel?.takeIf(String::isNotBlank) ?: remoteDisplayName?.takeIf(String::isNotBlank) ?: stringResource(Res.string.saved_devices_unnamed) -private fun shortEndpoint(endpointId: String): String = +internal fun shortDeviceEndpoint(endpointId: String): String = if (endpointId.length <= 20) endpointId else endpointId.take(16) + "…" diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDeviceTransferHistory.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDeviceTransferHistory.kt index 6dd2e86..7401d5e 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDeviceTransferHistory.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDeviceTransferHistory.kt @@ -42,7 +42,6 @@ import vnidrop.shared.generated.resources.saved_devices_transfer_progress import vnidrop.shared.generated.resources.saved_devices_transfer_receive import vnidrop.shared.generated.resources.saved_devices_transfer_resume import vnidrop.shared.generated.resources.saved_devices_transfers_title -import vnidrop.shared.generated.resources.saved_devices_endpoint import vnidrop.shared.generated.resources.saved_devices_unnamed import vnidrop.shared.generated.resources.status_approved import vnidrop.shared.generated.resources.status_awaiting_approval @@ -63,24 +62,21 @@ enum class SavedDeviceTransferAction { Delete, } -internal enum class TargetedTransferPresentation { - CompactRow, - DesktopRow, -} - internal fun LazyListScope.targetedTransferItems( transfers: List, busyTransferIds: Set, onAction: (String, SavedDeviceTransferAction) -> Unit, - presentation: TargetedTransferPresentation = TargetedTransferPresentation.CompactRow, + showHeading: Boolean = true, ) { - item(key = "targeted-transfer-title") { - Text( - text = stringResource(Res.string.saved_devices_transfers_title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(top = 8.dp).semantics { heading() }, - ) + if (showHeading) { + item(key = "targeted-transfer-title") { + Text( + text = stringResource(Res.string.saved_devices_transfers_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 8.dp).semantics { heading() }, + ) + } } if (transfers.isNotEmpty()) { itemsIndexed(transfers, key = { _, transfer -> "targeted-${transfer.id}" }) { index, transfer -> @@ -90,7 +86,6 @@ internal fun LazyListScope.targetedTransferItems( TargetedTransferRow( transfer = transfer, busy = transfer.id in busyTransferIds, - presentation = presentation, onAction = { action -> onAction(transfer.id, action) }, ) } @@ -101,7 +96,6 @@ internal fun LazyListScope.targetedTransferItems( private fun TargetedTransferRow( transfer: SavedDeviceTransferItem, busy: Boolean, - presentation: TargetedTransferPresentation, onAction: (SavedDeviceTransferAction) -> Unit, ) { val colors = LocalVniDropColors.current @@ -109,10 +103,7 @@ private fun TargetedTransferRow( (transfer.verifiedBytes.toDouble() / transfer.totalSize.toDouble()).coerceIn(0.0, 1.0).toFloat() } Column( - Modifier.fillMaxWidth().padding( - horizontal = if (presentation == TargetedTransferPresentation.DesktopRow) 4.dp else 0.dp, - vertical = 14.dp, - ), + Modifier.fillMaxWidth().padding(vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -163,27 +154,6 @@ private fun TargetedTransferRow( style = MaterialTheme.typography.bodySmall, color = colors.foregroundLighter, ) - if (presentation == TargetedTransferPresentation.DesktopRow) { - Text( - text = stringResource( - Res.string.saved_devices_endpoint, - shortTransferEndpoint(transfer.peerEndpointId), - ), - style = MaterialTheme.typography.bodySmall, - color = colors.foregroundLighter, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - if (presentation == TargetedTransferPresentation.CompactRow) { - Text( - text = stringResource(Res.string.saved_devices_endpoint, shortTransferEndpoint(transfer.peerEndpointId)), - style = MaterialTheme.typography.bodySmall, - color = colors.foregroundLighter, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) } if ( transfer.direction == SavedDeviceTransferDirection.Incoming && @@ -282,6 +252,3 @@ private fun TargetedTransferStateModel.isDeletable(): Boolean = this in setOf( TargetedTransferStateModel.Cancelled, TargetedTransferStateModel.Failed, ) - -private fun shortTransferEndpoint(endpointId: String): String = - if (endpointId.length <= 20) endpointId else endpointId.take(16) + "…" diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesScreen.kt index c3233c3..45759f0 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme @@ -17,6 +18,10 @@ 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.heading @@ -32,7 +37,6 @@ import org.jetbrains.compose.resources.stringResource import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.button_cancel import vnidrop.shared.generated.resources.button_retry -import vnidrop.shared.generated.resources.saved_devices_description import vnidrop.shared.generated.resources.saved_devices_label_clear import vnidrop.shared.generated.resources.saved_devices_label_placeholder import vnidrop.shared.generated.resources.saved_devices_label_save @@ -63,14 +67,21 @@ internal fun SavedDevicesScreen( onClearLabel: () -> Unit, onDismissLabel: () -> Unit, ) { + var selectedDeviceId by remember { mutableStateOf(null) } + val selectedDevice = state.savedDevices.firstOrNull { it.endpointId == selectedDeviceId } val hasContent = state.eligibilities.isNotEmpty() || state.pendingRelationships.isNotEmpty() || state.savedDevices.isNotEmpty() || state.targetedTransfers.isNotEmpty() || state.targetedOffers.pending.isNotEmpty() Column( modifier = modifier.fillMaxSize().statusBarsPadding().padding(top = 16.dp), ) { - SavedDevicesHeader(Modifier.padding(horizontal = if (windowClass == WindowClass.Desktop) 24.dp else 16.dp)) - Spacer(Modifier.height(20.dp)) + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + SavedDevicesHeader( + Modifier.fillMaxWidth().widthIn(max = 720.dp) + .padding(horizontal = if (windowClass == WindowClass.Desktop) 24.dp else 16.dp), + ) + } + Spacer(Modifier.height(14.dp)) when { state.isLoading && !hasContent -> SavedDevicesLoading(Modifier.weight(1f)) state.loadFailed && !hasContent -> SavedDevicesLoadFailure(onRetry, Modifier.weight(1f)) @@ -83,11 +94,7 @@ internal fun SavedDevicesScreen( onDeclineIncoming = onDeclineIncoming, onAcceptOffer = onAcceptOffer, onDeclineOffer = onDeclineOffer, - onSend = onSend, - onOpenLabel = onOpenLabel, - onForget = onForget, - onBlock = onBlock, - onTransferAction = onTransferAction, + onOpenDevice = { selectedDeviceId = it }, ) else -> CompactSavedDevicesHub( state = state, @@ -98,17 +105,32 @@ internal fun SavedDevicesScreen( onDeclineIncoming = onDeclineIncoming, onAcceptOffer = onAcceptOffer, onDeclineOffer = onDeclineOffer, - onSend = onSend, - onOpenLabel = onOpenLabel, - onForget = onForget, - onBlock = onBlock, - onTransferAction = onTransferAction, + onOpenDevice = { selectedDeviceId = it }, ) } } + if (selectedDevice != null) { + SavedDeviceDetailsDrawer( + device = selectedDevice, + transfers = state.targetedTransfers.filter { it.peerEndpointId == selectedDevice.endpointId }, + busy = selectedDevice.endpointId in state.busyPeerIds, + busyTransferIds = state.busyTransferIds, + windowClass = windowClass, + onDismiss = { selectedDeviceId = null }, + onSend = { + selectedDeviceId = null + onSend(selectedDevice.endpointId) + }, + onOpenLabel = { onOpenLabel(selectedDevice.endpointId) }, + onForget = { onForget(selectedDevice.endpointId) }, + onBlock = { onBlock(selectedDevice.endpointId) }, + onTransferAction = onTransferAction, + ) + } SavedDeviceLabelDialog( visible = state.labelingPeerId != null, label = state.labelDraft, + saving = state.isSavingLabel, onLabelChanged = onLabelDraftChanged, onSave = onSaveLabel, onClear = onClearLabel, @@ -118,19 +140,12 @@ internal fun SavedDevicesScreen( @Composable private fun SavedDevicesHeader(modifier: Modifier = Modifier) { - Column(modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = stringResource(Res.string.saved_devices_list_title), - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.semantics { heading() }, - ) - Text( - text = stringResource(Res.string.saved_devices_description), - style = MaterialTheme.typography.bodyMedium, - color = LocalVniDropColors.current.foregroundLight, - ) - } + Text( + text = stringResource(Res.string.saved_devices_list_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = modifier.semantics { heading() }, + ) } @Composable @@ -158,6 +173,7 @@ private fun SavedDevicesLoadFailure(onRetry: () -> Unit, modifier: Modifier = Mo private fun SavedDeviceLabelDialog( visible: Boolean, label: String, + saving: Boolean, onLabelChanged: (String) -> Unit, onSave: () -> Unit, onClear: () -> Unit, @@ -165,24 +181,28 @@ private fun SavedDeviceLabelDialog( ) { if (!visible) return AlertDialog( - onDismissRequest = onDismiss, + onDismissRequest = { if (!saving) onDismiss() }, title = { Text(stringResource(Res.string.saved_devices_label_title)) }, text = { OutlinedTextField( value = label, onValueChange = onLabelChanged, modifier = Modifier.fillMaxWidth(), + enabled = !saving, singleLine = true, placeholder = { Text(stringResource(Res.string.saved_devices_label_placeholder)) }, ) }, confirmButton = { - TextButton(onClick = onSave) { Text(stringResource(Res.string.saved_devices_label_save)) } + TextButton(onClick = onSave, enabled = !saving) { + if (saving) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + else Text(stringResource(Res.string.saved_devices_label_save)) + } }, dismissButton = { androidx.compose.foundation.layout.Row { - TextButton(onClick = onClear) { Text(stringResource(Res.string.saved_devices_label_clear)) } - TextButton(onClick = onDismiss) { Text(stringResource(Res.string.button_cancel)) } + TextButton(onClick = onClear, enabled = !saving) { Text(stringResource(Res.string.saved_devices_label_clear)) } + TextButton(onClick = onDismiss, enabled = !saving) { Text(stringResource(Res.string.button_cancel)) } } }, ) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModel.kt index 2d102ce..80600ef 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModel.kt @@ -48,6 +48,7 @@ data class SavedDevicesState( val busyTransferIds: Set = emptySet(), val labelingPeerId: String? = null, val labelDraft: String = "", + val isSavingLabel: Boolean = false, ) /** @@ -166,39 +167,53 @@ class SavedDevicesViewModel( } fun openLabelEditor(peerEndpointId: String) { + if (_state.value.isSavingLabel || peerEndpointId in _state.value.busyPeerIds) return val current = _state.value.savedDevices.firstOrNull { it.endpointId == peerEndpointId } _state.update { it.copy(labelingPeerId = peerEndpointId, labelDraft = current?.localLabel.orEmpty()) } } - fun setLabelDraft(value: String) = _state.update { it.copy(labelDraft = value) } + fun setLabelDraft(value: String) = _state.update { + if (it.isSavingLabel) it else it.copy(labelDraft = value) + } fun dismissLabelEditor() { - if (_state.value.labelingPeerId !in _state.value.busyPeerIds) { + if (!_state.value.isSavingLabel) { _state.update { it.copy(labelingPeerId = null, labelDraft = "") } } } fun saveLabel() { - val peerId = _state.value.labelingPeerId ?: return - val label = _state.value.labelDraft.trim().ifBlank { null } - mutatePeer(peerId) { - repository.setSavedDeviceLabel(peerId, label).onSuccess { - messages.tryShow(UiMessage(UiText.Resource(Res.string.saved_devices_labeled), UiMessageTone.Success)) - } - } - _state.update { it.copy(labelingPeerId = null, labelDraft = "") } + commitLabel(_state.value.labelDraft.trim().ifBlank { null }) } fun clearLabel() { + commitLabel(null) + } + + private fun commitLabel(label: String?) { val peerId = _state.value.labelingPeerId ?: return - mutatePeer(peerId) { - repository.setSavedDeviceLabel(peerId, null).onSuccess { + if (_state.value.isSavingLabel || peerId in _state.value.busyPeerIds) return + _state.update { it.copy(isSavingLabel = true, busyPeerIds = it.busyPeerIds + peerId) } + viewModelScope.launch { + val result = repository.setSavedDeviceLabel(peerId, label) + if (result.isSuccess) { + refresh() messages.tryShow(UiMessage(UiText.Resource(Res.string.saved_devices_labeled), UiMessageTone.Success)) + } else { + result.exceptionOrNull()?.let(messages::error) + } + _state.update { current -> + val editorMatches = current.labelingPeerId == peerId + current.copy( + busyPeerIds = current.busyPeerIds - peerId, + isSavingLabel = false, + labelingPeerId = if (result.isSuccess && editorMatches) null else current.labelingPeerId, + labelDraft = if (result.isSuccess && editorMatches) "" else current.labelDraft, + ) } } - _state.update { it.copy(labelingPeerId = null, labelDraft = "") } } fun forget(peerEndpointId: String) = mutatePeer(peerEndpointId) { diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/icons/PlatformIcons.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/icons/PlatformIcons.kt index 909041e..fddf0f1 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/icons/PlatformIcons.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/icons/PlatformIcons.kt @@ -30,6 +30,7 @@ internal enum class AppIcon( Code(Res.drawable.icon_material_code, Res.drawable.icon_fluent_code, Res.drawable.icon_lucide_code), CloudOff(Res.drawable.icon_material_cloud_off, Res.drawable.icon_fluent_cloud_off, Res.drawable.icon_lucide_cloud_off), Delete(Res.drawable.icon_material_delete, Res.drawable.icon_fluent_delete, Res.drawable.icon_lucide_delete), + Device(Res.drawable.icon_material_device, Res.drawable.icon_fluent_device, Res.drawable.icon_lucide_device), Document(Res.drawable.icon_material_document, Res.drawable.icon_fluent_document, Res.drawable.icon_lucide_document), Download(Res.drawable.icon_material_download, Res.drawable.icon_fluent_download, Res.drawable.icon_lucide_download), File(Res.drawable.icon_material_file, Res.drawable.icon_fluent_file, Res.drawable.icon_lucide_file), diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/navigation/AppDestination.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/navigation/AppDestination.kt index 8fef68d..b1572a9 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/navigation/AppDestination.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/navigation/AppDestination.kt @@ -24,6 +24,6 @@ internal data class NavigationItem( internal val primaryNavigationItems = listOf( NavigationItem(AppDestination.Send, Res.string.nav_send, AppIcon.Send), NavigationItem(AppDestination.Receive, Res.string.nav_receive, AppIcon.Download), - NavigationItem(AppDestination.SavedDevices, Res.string.nav_saved_devices, AppIcon.ShieldCheck), + NavigationItem(AppDestination.SavedDevices, Res.string.nav_saved_devices, AppIcon.Device), NavigationItem(AppDestination.Settings, Res.string.nav_settings, AppIcon.Settings), ) diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModelTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModelTest.kt index 72747c7..282b92d 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModelTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModelTest.kt @@ -19,6 +19,7 @@ import com.vnidrop.app.ui.feedback.UiMessageController import com.vnidrop.app.ui.theme.ThemeMode import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -228,6 +229,55 @@ class SavedDevicesViewModelTest { assertEquals(listOf("peer-2"), core.blockedPeers) } + @Test + fun failedLabelSaveKeepsTheEditorAndDraftAvailableForRetry() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = initializedCore().apply { + savedDevices = listOf(device("peer-1", null, "Kitchen tablet")) + setSavedDeviceLabelResult = Result.failure(IllegalStateException("save failed")) + } + val viewModel = createViewModel(core) + runCurrent() + advanceUntilIdle() + + viewModel.openLabelEditor("peer-1") + viewModel.setLabelDraft("Kitchen") + viewModel.saveLabel() + runCurrent() + advanceUntilIdle() + + assertEquals("peer-1", viewModel.state.value.labelingPeerId) + assertEquals("Kitchen", viewModel.state.value.labelDraft) + } + + @Test + fun labelEditorCannotDismissOrLoseItsDraftWhileSaveIsInFlight() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val releaseSave = CompletableDeferred() + val core = initializedCore().apply { + savedDevices = listOf(device("peer-1", null, "Kitchen tablet")) + beforeSetSavedDeviceLabel = { releaseSave.await() } + } + val viewModel = createViewModel(core) + runCurrent() + advanceUntilIdle() + + viewModel.openLabelEditor("peer-1") + viewModel.setLabelDraft("Kitchen") + viewModel.saveLabel() + runCurrent() + viewModel.dismissLabelEditor() + + assertTrue(viewModel.state.value.isSavingLabel) + assertEquals("peer-1", viewModel.state.value.labelingPeerId) + assertEquals("Kitchen", viewModel.state.value.labelDraft) + + releaseSave.complete(Unit) + advanceUntilIdle() + assertEquals(false, viewModel.state.value.isSavingLabel) + assertNull(viewModel.state.value.labelingPeerId) + } + private fun createViewModel( core: FakeCoreGateway, fileSystemService: FakeFileSystemService = FakeFileSystemService(receiveFolder()), diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt index fcdf1b4..18ddf84 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt @@ -221,6 +221,8 @@ class FakeCoreGateway : CoreGateway { val forgottenDevices = mutableListOf() val blockedPeers = mutableListOf() val labeledDevices = mutableListOf>() + var beforeSetSavedDeviceLabel: suspend () -> Unit = {} + var setSavedDeviceLabelResult: Result = Result.success(Unit) override suspend fun listPairingEligibilities(): Result> { listPairingEligibilitiesCount += 1 @@ -262,11 +264,13 @@ class FakeCoreGateway : CoreGateway { return Result.success(savedDevices) } override suspend fun setSavedDeviceLabel(peerEndpointId: String, label: String?): Result { + beforeSetSavedDeviceLabel() labeledDevices += peerEndpointId to label + if (setSavedDeviceLabelResult.isFailure) return setSavedDeviceLabelResult savedDevices = savedDevices.map { if (it.endpointId == peerEndpointId) it.copy(localLabel = label) else it } - return Result.success(Unit) + return setSavedDeviceLabelResult } override suspend fun forgetSavedDevice(peerEndpointId: String): Result { forgottenDevices += peerEndpointId diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesScreenTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesScreenTest.kt index 8aefb9d..49e23ab 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesScreenTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesScreenTest.kt @@ -6,8 +6,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.onAllNodesWithText -import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText @@ -34,7 +35,6 @@ import vnidrop.shared.generated.resources.button_retry import vnidrop.shared.generated.resources.saved_devices_block_action import vnidrop.shared.generated.resources.saved_devices_block_confirm_title import vnidrop.shared.generated.resources.saved_devices_attention_title -import vnidrop.shared.generated.resources.saved_devices_devices_title import vnidrop.shared.generated.resources.saved_devices_empty import vnidrop.shared.generated.resources.saved_devices_empty_title import vnidrop.shared.generated.resources.saved_devices_forget_action @@ -64,7 +64,6 @@ class SavedDevicesScreenTest { runOnIdle { state.value = SavedDevicesState(isLoading = false) } onNodeWithText(Res.string.saved_devices_empty_title.value).assertIsDisplayed() onNodeWithText(Res.string.saved_devices_empty.value).assertIsDisplayed() - onAllNodesWithText(Res.string.saved_devices_devices_title.value).assertCountEquals(0) onAllNodesWithText(Res.string.saved_devices_transfers_title.value).assertCountEquals(0) } @@ -87,8 +86,10 @@ class SavedDevicesScreenTest { } onNodeWithText("Office laptop").assertIsDisplayed() - onNodeWithText("Remote name: Amira's PC").assertIsDisplayed() + onAllNodesWithText("Remote name: Amira's PC").assertCountEquals(0) onAllNodesWithText("Pixel 9", useUnmergedTree = true).assertCountEquals(2) + onNodeWithTag("saved-device-saved-peer-long-identifier").performClick() + onNodeWithText("Remote name: Amira's PC").assertIsDisplayed() onNodeWithText("Device ID: saved-peer-long-…").assertIsDisplayed() } @@ -108,10 +109,11 @@ class SavedDevicesScreenTest { } } } - onAllNodesWithContentDescription(Res.string.saved_devices_send_action.value).assertCountEquals(0) + onNodeWithTag("saved-device-peer-one").performClick() + onNodeWithText(Res.string.saved_devices_send_action.value).assertIsNotEnabled() runOnIdle { state.value = state.value.copy(busyPeerIds = emptySet()) } - onNodeWithContentDescription(Res.string.saved_devices_send_action.value).assertIsDisplayed() + onNodeWithText(Res.string.saved_devices_send_action.value).assertIsEnabled() onNodeWithContentDescription("More actions for Riley's phone").performClick() onNodeWithText(Res.string.saved_devices_block_action.value).performClick() onNodeWithText(Res.string.saved_devices_block_confirm_title.value).assertIsDisplayed() @@ -133,12 +135,13 @@ class SavedDevicesScreenTest { } } } + onNodeWithTag("saved-device-peer-two").performClick() onNodeWithContentDescription("More actions for Desktop").performClick() onNodeWithText(Res.string.saved_devices_forget_action.value).assertIsDisplayed() } @Test - fun directTransferHistoryShowsProgressAndRoutesResumeById() = runComposeUiTest { + fun deviceTransfersStayHiddenUntilTheDeviceDetailsAreOpened() = runComposeUiTest { val actions = mutableListOf>() setContent { CompositionLocalProvider(LocalUiPlatform provides UiPlatform.Android) { @@ -146,6 +149,7 @@ class SavedDevicesScreenTest { SavedDevicesScreen( state = SavedDevicesState( isLoading = false, + savedDevices = listOf(device("peer", null, "Office PC")), targetedTransfers = listOf( SavedDeviceTransferItem( id = "transfer-resume", @@ -169,9 +173,12 @@ class SavedDevicesScreenTest { } } + onAllNodesWithText(Res.string.saved_devices_transfers_title.value).assertCountEquals(0) + onAllNodesWithText("Project files").assertCountEquals(0) + onNodeWithTag("saved-device-peer").performClick() onNodeWithText(Res.string.saved_devices_transfers_title.value).assertIsDisplayed() onNodeWithText("Project files").assertIsDisplayed() - onNodeWithText("Office PC", substring = true).assertIsDisplayed() + onAllNodesWithText("%", substring = true).assertCountEquals(0) onNodeWithText(Res.string.saved_devices_transfer_resume.value).performClick() runOnIdle { assertEquals(listOf("transfer-resume" to SavedDeviceTransferAction.Resume), actions) @@ -203,7 +210,6 @@ class SavedDevicesScreenTest { } } - onNodeWithText(Res.string.saved_devices_devices_title.value).assertIsDisplayed() onNodeWithText(Res.string.saved_devices_attention_title.value).assertIsDisplayed() onNodeWithText("Amira's phone").assertIsDisplayed() onNodeWithText("Studio PC").assertIsDisplayed() @@ -232,7 +238,7 @@ class SavedDevicesScreenTest { val attentionTop = onNodeWithText(Res.string.saved_devices_attention_title.value) .fetchSemanticsNode().boundsInRoot.top - val devicesTop = onNodeWithText(Res.string.saved_devices_devices_title.value) + val devicesTop = onNodeWithTag("saved-device-peer-one") .fetchSemanticsNode().boundsInRoot.top assertTrue(attentionTop < devicesTop, "compact layouts should surface pending decisions before the device list") } diff --git a/shared/tools/import_platform_icons.py b/shared/tools/import_platform_icons.py index 3633f87..78891b8 100644 --- a/shared/tools/import_platform_icons.py +++ b/shared/tools/import_platform_icons.py @@ -39,6 +39,7 @@ ICONS = ( IconSource("code", "code", "Code", "code", "code-xml"), IconSource("cloud_off", "cloud_off", "Cloud Off", "cloud_off", "cloud-off"), IconSource("delete", "delete", "Delete", "delete", "trash-2"), + IconSource("device", "devices", "Desktop", "desktop", "monitor"), IconSource("document", "description", "Document", "document", "file-text"), IconSource("download", "download", "Arrow Download", "arrow_download", "download"), IconSource("file", "draft", "Document", "document", "file"),