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"),