feat(shared): refine saved devices experience

This commit is contained in:
2026-08-13 13:18:56 +02:00
parent ac77950ac3
commit beab4100ed
26 changed files with 572 additions and 457 deletions

View File

@@ -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. 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. - 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 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. - 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. - Keep accent color scarce. Let status, selection, or the primary action earn it.

View File

@@ -3,6 +3,7 @@ import { STRINGS_JSON } from "../config";
import type { StringsFile } from "../types"; import type { StringsFile } from "../types";
const TOKEN = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g; const TOKEN = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
const PLATFORM_PLACEHOLDER = /%(?:\d+\$)?(?:l{0,2})?[@sdif]/;
export async function validate() { export async function validate() {
const doc = JSON.parse(await Bun.file(STRINGS_JSON).text()) as StringsFile; 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) { 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)) { for (const m of text.matchAll(TOKEN)) {
if (!argNames.has(m[1]!)) { if (!argNames.has(m[1]!)) {
errors.push(`${where}: uses {${m[1]}} but no matching arg is declared.`); errors.push(`${where}: uses {${m[1]}} but no matching arg is declared.`);

View File

@@ -4843,21 +4843,6 @@
}, },
"targets": ["kmp"] "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": { "saved_devices_transfers_title": {
"context": "Saved devices screen: heading for durable targeted-transfer history.", "context": "Saved devices screen: heading for durable targeted-transfer history.",
"translations": { "translations": {
@@ -4892,15 +4877,15 @@
"context": "Targeted transfer card: incoming direction followed by the saved-device display name.", "context": "Targeted transfer card: incoming direction followed by the saved-device display name.",
"args": [{"name": "device", "type": "string"}], "args": [{"name": "device", "type": "string"}],
"translations": { "translations": {
"en": "From %1$s", "en": "From {device}",
"fr": "De %1$s", "fr": "De {device}",
"es": "De %1$s", "es": "De {device}",
"it": "Da %1$s", "it": "Da {device}",
"de": "Von %1$s", "de": "Von {device}",
"pt": "De %1$s", "pt": "De {device}",
"pl": "Od %1$s", "pl": "Od {device}",
"nl": "Van %1$s", "nl": "Van {device}",
"ru": "От %1$s" "ru": "От {device}"
}, },
"targets": ["kmp"] "targets": ["kmp"]
}, },
@@ -4908,15 +4893,15 @@
"context": "Targeted transfer card: outgoing direction followed by the saved-device display name.", "context": "Targeted transfer card: outgoing direction followed by the saved-device display name.",
"args": [{"name": "device", "type": "string"}], "args": [{"name": "device", "type": "string"}],
"translations": { "translations": {
"en": "To %1$s", "en": "To {device}",
"fr": "Vers %1$s", "fr": "Vers {device}",
"es": "A %1$s", "es": "A {device}",
"it": "A %1$s", "it": "A {device}",
"de": "An %1$s", "de": "An {device}",
"pt": "Para %1$s", "pt": "Para {device}",
"pl": "Do %1$s", "pl": "Do {device}",
"nl": "Naar %1$s", "nl": "Naar {device}",
"ru": "Для %1$s" "ru": "Для {device}"
}, },
"targets": ["kmp"] "targets": ["kmp"]
}, },
@@ -4924,15 +4909,15 @@
"context": "Targeted transfer card metadata. First placeholder is file count, second is formatted total size.", "context": "Targeted transfer card metadata. First placeholder is file count, second is formatted total size.",
"args": [{"name": "count", "type": "string"}, {"name": "size", "type": "string"}], "args": [{"name": "count", "type": "string"}, {"name": "size", "type": "string"}],
"translations": { "translations": {
"en": "%1$s files · %2$s", "en": "{count} files · {size}",
"fr": "%1$s fichiers · %2$s", "fr": "{count} fichiers · {size}",
"es": "%1$s archivos · %2$s", "es": "{count} archivos · {size}",
"it": "%1$s file · %2$s", "it": "{count} file · {size}",
"de": "%1$s Dateien · %2$s", "de": "{count} Dateien · {size}",
"pt": "%1$s ficheiros · %2$s", "pt": "{count} ficheiros · {size}",
"pl": "%1$s plików · %2$s", "pl": "{count} plików · {size}",
"nl": "%1$s bestanden · %2$s", "nl": "{count} bestanden · {size}",
"ru": "%1$s файлов · %2$s" "ru": "{count} файлов · {size}"
}, },
"targets": ["kmp"] "targets": ["kmp"]
}, },
@@ -4940,15 +4925,15 @@
"context": "Targeted transfer card progress. First placeholder is verified bytes, second is total size.", "context": "Targeted transfer card progress. First placeholder is verified bytes, second is total size.",
"args": [{"name": "verified", "type": "string"}, {"name": "total", "type": "string"}], "args": [{"name": "verified", "type": "string"}, {"name": "total", "type": "string"}],
"translations": { "translations": {
"en": "%1$s of %2$s", "en": "{verified} of {total}",
"fr": "%1$s sur %2$s", "fr": "{verified} sur {total}",
"es": "%1$s de %2$s", "es": "{verified} de {total}",
"it": "%1$s di %2$s", "it": "{verified} di {total}",
"de": "%1$s von %2$s", "de": "{verified} von {total}",
"pt": "%1$s de %2$s", "pt": "{verified} de {total}",
"pl": "%1$s z %2$s", "pl": "{verified} z {total}",
"nl": "%1$s van %2$s", "nl": "{verified} van {total}",
"ru": "%1$s из %2$s" "ru": "{verified} из {total}"
}, },
"targets": ["kmp"] "targets": ["kmp"]
}, },
@@ -5058,23 +5043,6 @@
"kmp" "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": { "saved_devices_endpoint": {
"context": "Saved-device screen: secondary diagnostic endpoint identity. Placeholder is a shortened endpoint ID.", "context": "Saved-device screen: secondary diagnostic endpoint identity. Placeholder is a shortened endpoint ID.",
"translations": { "translations": {

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M6.75 22.0004C6.33579 22.0004 6 21.6647 6 21.2504C6 20.8707 6.28215 20.557 6.64823 20.5073L6.75 20.5004L8.499 20.5V18.002L4.25 18.0023C3.05914 18.0023 2.08436 17.0771 2.00519 15.9063L2 15.7523V5.25C2 4.05914 2.92516 3.08436 4.09595 3.00519L4.25 3H19.7488C20.9397 3 21.9145 3.92516 21.9936 5.09595L21.9988 5.25V15.7523C21.9988 16.9431 21.0737 17.9179 19.9029 17.9971L19.7488 18.0023L15.499 18.002V20.5L17.25 20.5004C17.6642 20.5004 18 20.8362 18 21.2504C18 21.6301 17.7178 21.9439 17.3518 21.9936L17.25 22.0004H6.75ZM13.998 18.002H9.998L9.999 20.5004H13.999L13.998 18.002ZM19.7488 4.5H4.25C3.8703 4.5 3.55651 4.78215 3.50685 5.14823L3.5 5.25V15.7523C3.5 16.132 3.78215 16.4458 4.14823 16.4954L4.25 16.5023H19.7488C20.1285 16.5023 20.4423 16.2201 20.492 15.854L20.4988 15.7523V5.25C20.4988 4.8703 20.2167 4.55651 19.8506 4.50685L19.7488 4.5Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M 4 3 H 20 A 2 2 0 0 1 22 5 V 15 A 2 2 0 0 1 20 17 H 4 A 2 2 0 0 1 2 15 V 5 A 2 2 0 0 1 4 3 Z" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M 8 21 L 16 21" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M 12 17 L 12 21" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="960" android:viewportHeight="960">
<!-- material icon imported by shared/tools/import_platform_icons.py. -->
<path android:fillColor="#FF000000" android:pathData="M140,800Q115,800 97.5,782.5Q80,765 80,740Q80,715 97.5,697.5Q115,680 140,680L160,680L160,240Q160,207 183.5,183.5Q207,160 240,160L800,160Q817,160 828.5,171.5Q840,183 840,200Q840,217 828.5,228.5Q817,240 800,240L240,240Q240,240 240,240Q240,240 240,240L240,680L420,680Q445,680 462.5,697.5Q480,715 480,740Q480,765 462.5,782.5Q445,800 420,800L140,800ZM600,800Q583,800 571.5,788.5Q560,777 560,760L560,360Q560,343 571.5,331.5Q583,320 600,320L840,320Q857,320 868.5,331.5Q880,343 880,360L880,760Q880,777 868.5,788.5Q857,800 840,800L600,800ZM640,680L800,680L800,400L640,400L640,680ZM640,680L640,680L800,680L800,680L640,680Z" />
</vector>

View File

@@ -320,13 +320,12 @@
<string name="offer_accept">Empfangen</string> <string name="offer_accept">Empfangen</string>
<string name="offer_decline">Ablehnen</string> <string name="offer_decline">Ablehnen</string>
<string name="saved_devices_attention_title">Erfordert Aufmerksamkeit</string> <string name="saved_devices_attention_title">Erfordert Aufmerksamkeit</string>
<string name="saved_devices_devices_title">Deine Geräte</string>
<string name="saved_devices_transfers_title">Direkte Übertragungen</string> <string name="saved_devices_transfers_title">Direkte Übertragungen</string>
<string name="saved_devices_transfer_empty">Noch keine direkten Übertragungen.</string> <string name="saved_devices_transfer_empty">Noch keine direkten Übertragungen.</string>
<string name="saved_devices_transfer_direction_incoming">Von %%1$s</string> <string name="saved_devices_transfer_direction_incoming">Von %1$s</string>
<string name="saved_devices_transfer_direction_outgoing">An %%1$s</string> <string name="saved_devices_transfer_direction_outgoing">An %1$s</string>
<string name="saved_devices_transfer_files">%%1$s Dateien · %%2$s</string> <string name="saved_devices_transfer_files">%1$s Dateien · %2$s</string>
<string name="saved_devices_transfer_progress">%%1$s von %%2$s</string> <string name="saved_devices_transfer_progress">%1$s von %2$s</string>
<string name="saved_devices_transfer_receive">Empfangen</string> <string name="saved_devices_transfer_receive">Empfangen</string>
<string name="saved_devices_transfer_resume">Fortsetzen</string> <string name="saved_devices_transfer_resume">Fortsetzen</string>
<string name="saved_devices_transfer_cancel">Abbrechen</string> <string name="saved_devices_transfer_cancel">Abbrechen</string>
@@ -341,7 +340,6 @@
<string name="saved_devices_authenticated_name">Remote-Name: %1$s</string> <string name="saved_devices_authenticated_name">Remote-Name: %1$s</string>
<string name="saved_devices_block_confirm_body">%1$s blockieren und künftige Übertragungen gespeicherter Geräte ablehnen?</string> <string name="saved_devices_block_confirm_body">%1$s blockieren und künftige Übertragungen gespeicherter Geräte ablehnen?</string>
<string name="saved_devices_block_confirm_title">Gerät blockieren?</string> <string name="saved_devices_block_confirm_title">Gerät blockieren?</string>
<string name="saved_devices_description">Direkt an vertrauenswürdige Geräte senden, ohne eine neue Einladung zu teilen.</string>
<string name="saved_devices_endpoint">Geräte-ID: %1$s</string> <string name="saved_devices_endpoint">Geräte-ID: %1$s</string>
<string name="saved_devices_empty">Schließe eine Übertragung ab und speichere anschließend das andere Gerät.</string> <string name="saved_devices_empty">Schließe eine Übertragung ab und speichere anschließend das andere Gerät.</string>
<string name="saved_devices_empty_title">Keine gespeicherten Geräte</string> <string name="saved_devices_empty_title">Keine gespeicherten Geräte</string>

View File

@@ -320,13 +320,12 @@
<string name="offer_accept">Recibir</string> <string name="offer_accept">Recibir</string>
<string name="offer_decline">Rechazar</string> <string name="offer_decline">Rechazar</string>
<string name="saved_devices_attention_title">Requiere atención</string> <string name="saved_devices_attention_title">Requiere atención</string>
<string name="saved_devices_devices_title">Tus dispositivos</string>
<string name="saved_devices_transfers_title">Transferencias directas</string> <string name="saved_devices_transfers_title">Transferencias directas</string>
<string name="saved_devices_transfer_empty">Aún no hay transferencias directas.</string> <string name="saved_devices_transfer_empty">Aún no hay transferencias directas.</string>
<string name="saved_devices_transfer_direction_incoming">De %%1$s</string> <string name="saved_devices_transfer_direction_incoming">De %1$s</string>
<string name="saved_devices_transfer_direction_outgoing">A %%1$s</string> <string name="saved_devices_transfer_direction_outgoing">A %1$s</string>
<string name="saved_devices_transfer_files">%%1$s archivos · %%2$s</string> <string name="saved_devices_transfer_files">%1$s archivos · %2$s</string>
<string name="saved_devices_transfer_progress">%%1$s de %%2$s</string> <string name="saved_devices_transfer_progress">%1$s de %2$s</string>
<string name="saved_devices_transfer_receive">Recibir</string> <string name="saved_devices_transfer_receive">Recibir</string>
<string name="saved_devices_transfer_resume">Reanudar</string> <string name="saved_devices_transfer_resume">Reanudar</string>
<string name="saved_devices_transfer_cancel">Cancelar</string> <string name="saved_devices_transfer_cancel">Cancelar</string>
@@ -341,7 +340,6 @@
<string name="saved_devices_authenticated_name">Nombre remoto: %1$s</string> <string name="saved_devices_authenticated_name">Nombre remoto: %1$s</string>
<string name="saved_devices_block_confirm_body">¿Bloquear a %1$s y rechazar futuros envíos de dispositivos guardados?</string> <string name="saved_devices_block_confirm_body">¿Bloquear a %1$s y rechazar futuros envíos de dispositivos guardados?</string>
<string name="saved_devices_block_confirm_title">¿Bloquear dispositivo?</string> <string name="saved_devices_block_confirm_title">¿Bloquear dispositivo?</string>
<string name="saved_devices_description">Envía directamente a dispositivos de confianza sin compartir otra invitación.</string>
<string name="saved_devices_endpoint">ID del dispositivo: %1$s</string> <string name="saved_devices_endpoint">ID del dispositivo: %1$s</string>
<string name="saved_devices_empty">Finaliza una transferencia y elige recordar el otro dispositivo.</string> <string name="saved_devices_empty">Finaliza una transferencia y elige recordar el otro dispositivo.</string>
<string name="saved_devices_empty_title">No hay dispositivos guardados</string> <string name="saved_devices_empty_title">No hay dispositivos guardados</string>

View File

@@ -320,13 +320,12 @@
<string name="offer_accept">Recevoir</string> <string name="offer_accept">Recevoir</string>
<string name="offer_decline">Refuser</string> <string name="offer_decline">Refuser</string>
<string name="saved_devices_attention_title">Nécessite votre attention</string> <string name="saved_devices_attention_title">Nécessite votre attention</string>
<string name="saved_devices_devices_title">Vos appareils</string>
<string name="saved_devices_transfers_title">Transferts directs</string> <string name="saved_devices_transfers_title">Transferts directs</string>
<string name="saved_devices_transfer_empty">Aucun transfert direct pour le moment.</string> <string name="saved_devices_transfer_empty">Aucun transfert direct pour le moment.</string>
<string name="saved_devices_transfer_direction_incoming">De %%1$s</string> <string name="saved_devices_transfer_direction_incoming">De %1$s</string>
<string name="saved_devices_transfer_direction_outgoing">Vers %%1$s</string> <string name="saved_devices_transfer_direction_outgoing">Vers %1$s</string>
<string name="saved_devices_transfer_files">%%1$s fichiers · %%2$s</string> <string name="saved_devices_transfer_files">%1$s fichiers · %2$s</string>
<string name="saved_devices_transfer_progress">%%1$s sur %%2$s</string> <string name="saved_devices_transfer_progress">%1$s sur %2$s</string>
<string name="saved_devices_transfer_receive">Recevoir</string> <string name="saved_devices_transfer_receive">Recevoir</string>
<string name="saved_devices_transfer_resume">Reprendre</string> <string name="saved_devices_transfer_resume">Reprendre</string>
<string name="saved_devices_transfer_cancel">Annuler</string> <string name="saved_devices_transfer_cancel">Annuler</string>
@@ -341,7 +340,6 @@
<string name="saved_devices_authenticated_name">Nom distant : %1$s</string> <string name="saved_devices_authenticated_name">Nom distant : %1$s</string>
<string name="saved_devices_block_confirm_body">Bloquer %1$s et refuser les futurs transferts dappareil enregistré ?</string> <string name="saved_devices_block_confirm_body">Bloquer %1$s et refuser les futurs transferts dappareil enregistré ?</string>
<string name="saved_devices_block_confirm_title">Bloquer lappareil ?</string> <string name="saved_devices_block_confirm_title">Bloquer lappareil ?</string>
<string name="saved_devices_description">Envoyez directement aux appareils de confiance, sans partager une nouvelle invitation.</string>
<string name="saved_devices_endpoint">ID de lappareil : %1$s</string> <string name="saved_devices_endpoint">ID de lappareil : %1$s</string>
<string name="saved_devices_empty">Terminez un transfert, puis choisissez de mémoriser lautre appareil.</string> <string name="saved_devices_empty">Terminez un transfert, puis choisissez de mémoriser lautre appareil.</string>
<string name="saved_devices_empty_title">Aucun appareil enregistré</string> <string name="saved_devices_empty_title">Aucun appareil enregistré</string>

View File

@@ -320,13 +320,12 @@
<string name="offer_accept">Ricevi</string> <string name="offer_accept">Ricevi</string>
<string name="offer_decline">Rifiuta</string> <string name="offer_decline">Rifiuta</string>
<string name="saved_devices_attention_title">Richiede attenzione</string> <string name="saved_devices_attention_title">Richiede attenzione</string>
<string name="saved_devices_devices_title">I tuoi dispositivi</string>
<string name="saved_devices_transfers_title">Trasferimenti diretti</string> <string name="saved_devices_transfers_title">Trasferimenti diretti</string>
<string name="saved_devices_transfer_empty">Nessun trasferimento diretto.</string> <string name="saved_devices_transfer_empty">Nessun trasferimento diretto.</string>
<string name="saved_devices_transfer_direction_incoming">Da %%1$s</string> <string name="saved_devices_transfer_direction_incoming">Da %1$s</string>
<string name="saved_devices_transfer_direction_outgoing">A %%1$s</string> <string name="saved_devices_transfer_direction_outgoing">A %1$s</string>
<string name="saved_devices_transfer_files">%%1$s file · %%2$s</string> <string name="saved_devices_transfer_files">%1$s file · %2$s</string>
<string name="saved_devices_transfer_progress">%%1$s di %%2$s</string> <string name="saved_devices_transfer_progress">%1$s di %2$s</string>
<string name="saved_devices_transfer_receive">Ricevi</string> <string name="saved_devices_transfer_receive">Ricevi</string>
<string name="saved_devices_transfer_resume">Riprendi</string> <string name="saved_devices_transfer_resume">Riprendi</string>
<string name="saved_devices_transfer_cancel">Annulla</string> <string name="saved_devices_transfer_cancel">Annulla</string>
@@ -341,7 +340,6 @@
<string name="saved_devices_authenticated_name">Nome remoto: %1$s</string> <string name="saved_devices_authenticated_name">Nome remoto: %1$s</string>
<string name="saved_devices_block_confirm_body">Bloccare %1$s e rifiutare i futuri trasferimenti da dispositivi salvati?</string> <string name="saved_devices_block_confirm_body">Bloccare %1$s e rifiutare i futuri trasferimenti da dispositivi salvati?</string>
<string name="saved_devices_block_confirm_title">Bloccare il dispositivo?</string> <string name="saved_devices_block_confirm_title">Bloccare il dispositivo?</string>
<string name="saved_devices_description">Invia direttamente ai dispositivi attendibili senza condividere un altro invito.</string>
<string name="saved_devices_endpoint">ID dispositivo: %1$s</string> <string name="saved_devices_endpoint">ID dispositivo: %1$s</string>
<string name="saved_devices_empty">Completa un trasferimento, quindi scegli di ricordare laltro dispositivo.</string> <string name="saved_devices_empty">Completa un trasferimento, quindi scegli di ricordare laltro dispositivo.</string>
<string name="saved_devices_empty_title">Nessun dispositivo salvato</string> <string name="saved_devices_empty_title">Nessun dispositivo salvato</string>

View File

@@ -320,13 +320,12 @@
<string name="offer_accept">Ontvangen</string> <string name="offer_accept">Ontvangen</string>
<string name="offer_decline">Weigeren</string> <string name="offer_decline">Weigeren</string>
<string name="saved_devices_attention_title">Aandacht vereist</string> <string name="saved_devices_attention_title">Aandacht vereist</string>
<string name="saved_devices_devices_title">Je apparaten</string>
<string name="saved_devices_transfers_title">Directe overdrachten</string> <string name="saved_devices_transfers_title">Directe overdrachten</string>
<string name="saved_devices_transfer_empty">Nog geen directe overdrachten.</string> <string name="saved_devices_transfer_empty">Nog geen directe overdrachten.</string>
<string name="saved_devices_transfer_direction_incoming">Van %%1$s</string> <string name="saved_devices_transfer_direction_incoming">Van %1$s</string>
<string name="saved_devices_transfer_direction_outgoing">Naar %%1$s</string> <string name="saved_devices_transfer_direction_outgoing">Naar %1$s</string>
<string name="saved_devices_transfer_files">%%1$s bestanden · %%2$s</string> <string name="saved_devices_transfer_files">%1$s bestanden · %2$s</string>
<string name="saved_devices_transfer_progress">%%1$s van %%2$s</string> <string name="saved_devices_transfer_progress">%1$s van %2$s</string>
<string name="saved_devices_transfer_receive">Ontvangen</string> <string name="saved_devices_transfer_receive">Ontvangen</string>
<string name="saved_devices_transfer_resume">Hervatten</string> <string name="saved_devices_transfer_resume">Hervatten</string>
<string name="saved_devices_transfer_cancel">Annuleren</string> <string name="saved_devices_transfer_cancel">Annuleren</string>
@@ -341,7 +340,6 @@
<string name="saved_devices_authenticated_name">Externe naam: %1$s</string> <string name="saved_devices_authenticated_name">Externe naam: %1$s</string>
<string name="saved_devices_block_confirm_body">%1$s blokkeren en toekomstige overdrachten van opgeslagen apparaten weigeren?</string> <string name="saved_devices_block_confirm_body">%1$s blokkeren en toekomstige overdrachten van opgeslagen apparaten weigeren?</string>
<string name="saved_devices_block_confirm_title">Apparaat blokkeren?</string> <string name="saved_devices_block_confirm_title">Apparaat blokkeren?</string>
<string name="saved_devices_description">Stuur rechtstreeks naar vertrouwde apparaten zonder opnieuw een uitnodiging te delen.</string>
<string name="saved_devices_endpoint">Apparaat-ID: %1$s</string> <string name="saved_devices_endpoint">Apparaat-ID: %1$s</string>
<string name="saved_devices_empty">Voltooi een overdracht en kies daarna om het andere apparaat te onthouden.</string> <string name="saved_devices_empty">Voltooi een overdracht en kies daarna om het andere apparaat te onthouden.</string>
<string name="saved_devices_empty_title">Geen opgeslagen apparaten</string> <string name="saved_devices_empty_title">Geen opgeslagen apparaten</string>

View File

@@ -320,13 +320,12 @@
<string name="offer_accept">Odbierz</string> <string name="offer_accept">Odbierz</string>
<string name="offer_decline">Odrzuć</string> <string name="offer_decline">Odrzuć</string>
<string name="saved_devices_attention_title">Wymaga uwagi</string> <string name="saved_devices_attention_title">Wymaga uwagi</string>
<string name="saved_devices_devices_title">Twoje urządzenia</string>
<string name="saved_devices_transfers_title">Transfery bezpośrednie</string> <string name="saved_devices_transfers_title">Transfery bezpośrednie</string>
<string name="saved_devices_transfer_empty">Brak transferów bezpośrednich.</string> <string name="saved_devices_transfer_empty">Brak transferów bezpośrednich.</string>
<string name="saved_devices_transfer_direction_incoming">Od %%1$s</string> <string name="saved_devices_transfer_direction_incoming">Od %1$s</string>
<string name="saved_devices_transfer_direction_outgoing">Do %%1$s</string> <string name="saved_devices_transfer_direction_outgoing">Do %1$s</string>
<string name="saved_devices_transfer_files">%%1$s plików · %%2$s</string> <string name="saved_devices_transfer_files">%1$s plików · %2$s</string>
<string name="saved_devices_transfer_progress">%%1$s z %%2$s</string> <string name="saved_devices_transfer_progress">%1$s z %2$s</string>
<string name="saved_devices_transfer_receive">Odbierz</string> <string name="saved_devices_transfer_receive">Odbierz</string>
<string name="saved_devices_transfer_resume">Wznów</string> <string name="saved_devices_transfer_resume">Wznów</string>
<string name="saved_devices_transfer_cancel">Anuluj</string> <string name="saved_devices_transfer_cancel">Anuluj</string>
@@ -341,7 +340,6 @@
<string name="saved_devices_authenticated_name">Nazwa zdalna: %1$s</string> <string name="saved_devices_authenticated_name">Nazwa zdalna: %1$s</string>
<string name="saved_devices_block_confirm_body">Zablokować %1$s i odrzucać przyszłe transfery z zapisanych urządzeń?</string> <string name="saved_devices_block_confirm_body">Zablokować %1$s i odrzucać przyszłe transfery z zapisanych urządzeń?</string>
<string name="saved_devices_block_confirm_title">Zablokować urządzenie?</string> <string name="saved_devices_block_confirm_title">Zablokować urządzenie?</string>
<string name="saved_devices_description">Wysyłaj bezpośrednio do zaufanych urządzeń bez udostępniania kolejnego zaproszenia.</string>
<string name="saved_devices_endpoint">ID urządzenia: %1$s</string> <string name="saved_devices_endpoint">ID urządzenia: %1$s</string>
<string name="saved_devices_empty">Ukończ transfer, a następnie zapisz drugie urządzenie.</string> <string name="saved_devices_empty">Ukończ transfer, a następnie zapisz drugie urządzenie.</string>
<string name="saved_devices_empty_title">Brak zapisanych urządzeń</string> <string name="saved_devices_empty_title">Brak zapisanych urządzeń</string>

View File

@@ -320,13 +320,12 @@
<string name="offer_accept">Receber</string> <string name="offer_accept">Receber</string>
<string name="offer_decline">Recusar</string> <string name="offer_decline">Recusar</string>
<string name="saved_devices_attention_title">Requer atenção</string> <string name="saved_devices_attention_title">Requer atenção</string>
<string name="saved_devices_devices_title">Os seus dispositivos</string>
<string name="saved_devices_transfers_title">Transferências diretas</string> <string name="saved_devices_transfers_title">Transferências diretas</string>
<string name="saved_devices_transfer_empty">Ainda não existem transferências diretas.</string> <string name="saved_devices_transfer_empty">Ainda não existem transferências diretas.</string>
<string name="saved_devices_transfer_direction_incoming">De %%1$s</string> <string name="saved_devices_transfer_direction_incoming">De %1$s</string>
<string name="saved_devices_transfer_direction_outgoing">Para %%1$s</string> <string name="saved_devices_transfer_direction_outgoing">Para %1$s</string>
<string name="saved_devices_transfer_files">%%1$s ficheiros · %%2$s</string> <string name="saved_devices_transfer_files">%1$s ficheiros · %2$s</string>
<string name="saved_devices_transfer_progress">%%1$s de %%2$s</string> <string name="saved_devices_transfer_progress">%1$s de %2$s</string>
<string name="saved_devices_transfer_receive">Receber</string> <string name="saved_devices_transfer_receive">Receber</string>
<string name="saved_devices_transfer_resume">Retomar</string> <string name="saved_devices_transfer_resume">Retomar</string>
<string name="saved_devices_transfer_cancel">Cancelar</string> <string name="saved_devices_transfer_cancel">Cancelar</string>
@@ -341,7 +340,6 @@
<string name="saved_devices_authenticated_name">Nome remoto: %1$s</string> <string name="saved_devices_authenticated_name">Nome remoto: %1$s</string>
<string name="saved_devices_block_confirm_body">Bloquear %1$s e rejeitar futuras transferências de dispositivos guardados?</string> <string name="saved_devices_block_confirm_body">Bloquear %1$s e rejeitar futuras transferências de dispositivos guardados?</string>
<string name="saved_devices_block_confirm_title">Bloquear dispositivo?</string> <string name="saved_devices_block_confirm_title">Bloquear dispositivo?</string>
<string name="saved_devices_description">Envie diretamente para dispositivos de confiança sem partilhar outro convite.</string>
<string name="saved_devices_endpoint">ID do dispositivo: %1$s</string> <string name="saved_devices_endpoint">ID do dispositivo: %1$s</string>
<string name="saved_devices_empty">Conclua uma transferência e escolha guardar o outro dispositivo.</string> <string name="saved_devices_empty">Conclua uma transferência e escolha guardar o outro dispositivo.</string>
<string name="saved_devices_empty_title">Nenhum dispositivo guardado</string> <string name="saved_devices_empty_title">Nenhum dispositivo guardado</string>

View File

@@ -320,13 +320,12 @@
<string name="offer_accept">Получить</string> <string name="offer_accept">Получить</string>
<string name="offer_decline">Отклонить</string> <string name="offer_decline">Отклонить</string>
<string name="saved_devices_attention_title">Требует внимания</string> <string name="saved_devices_attention_title">Требует внимания</string>
<string name="saved_devices_devices_title">Ваши устройства</string>
<string name="saved_devices_transfers_title">Прямые передачи</string> <string name="saved_devices_transfers_title">Прямые передачи</string>
<string name="saved_devices_transfer_empty">Прямых передач пока нет.</string> <string name="saved_devices_transfer_empty">Прямых передач пока нет.</string>
<string name="saved_devices_transfer_direction_incoming">От %%1$s</string> <string name="saved_devices_transfer_direction_incoming">От %1$s</string>
<string name="saved_devices_transfer_direction_outgoing">Для %%1$s</string> <string name="saved_devices_transfer_direction_outgoing">Для %1$s</string>
<string name="saved_devices_transfer_files">%%1$s файлов · %%2$s</string> <string name="saved_devices_transfer_files">%1$s файлов · %2$s</string>
<string name="saved_devices_transfer_progress">%%1$s из %%2$s</string> <string name="saved_devices_transfer_progress">%1$s из %2$s</string>
<string name="saved_devices_transfer_receive">Получить</string> <string name="saved_devices_transfer_receive">Получить</string>
<string name="saved_devices_transfer_resume">Продолжить</string> <string name="saved_devices_transfer_resume">Продолжить</string>
<string name="saved_devices_transfer_cancel">Отменить</string> <string name="saved_devices_transfer_cancel">Отменить</string>
@@ -341,7 +340,6 @@
<string name="saved_devices_authenticated_name">Имя устройства: %1$s</string> <string name="saved_devices_authenticated_name">Имя устройства: %1$s</string>
<string name="saved_devices_block_confirm_body">Заблокировать %1$s и отклонять будущие передачи с сохранённых устройств?</string> <string name="saved_devices_block_confirm_body">Заблокировать %1$s и отклонять будущие передачи с сохранённых устройств?</string>
<string name="saved_devices_block_confirm_title">Заблокировать устройство?</string> <string name="saved_devices_block_confirm_title">Заблокировать устройство?</string>
<string name="saved_devices_description">Отправляйте напрямую доверенным устройствам без новой ссылки-приглашения.</string>
<string name="saved_devices_endpoint">ID устройства: %1$s</string> <string name="saved_devices_endpoint">ID устройства: %1$s</string>
<string name="saved_devices_empty">Завершите передачу, затем сохраните другое устройство.</string> <string name="saved_devices_empty">Завершите передачу, затем сохраните другое устройство.</string>
<string name="saved_devices_empty_title">Нет сохранённых устройств</string> <string name="saved_devices_empty_title">Нет сохранённых устройств</string>

View File

@@ -320,13 +320,12 @@
<string name="offer_accept">Receive</string> <string name="offer_accept">Receive</string>
<string name="offer_decline">Decline</string> <string name="offer_decline">Decline</string>
<string name="saved_devices_attention_title">Needs attention</string> <string name="saved_devices_attention_title">Needs attention</string>
<string name="saved_devices_devices_title">Your devices</string>
<string name="saved_devices_transfers_title">Direct transfers</string> <string name="saved_devices_transfers_title">Direct transfers</string>
<string name="saved_devices_transfer_empty">No direct transfers yet.</string> <string name="saved_devices_transfer_empty">No direct transfers yet.</string>
<string name="saved_devices_transfer_direction_incoming">From %%1$s</string> <string name="saved_devices_transfer_direction_incoming">From %1$s</string>
<string name="saved_devices_transfer_direction_outgoing">To %%1$s</string> <string name="saved_devices_transfer_direction_outgoing">To %1$s</string>
<string name="saved_devices_transfer_files">%%1$s files · %%2$s</string> <string name="saved_devices_transfer_files">%1$s files · %2$s</string>
<string name="saved_devices_transfer_progress">%%1$s of %%2$s</string> <string name="saved_devices_transfer_progress">%1$s of %2$s</string>
<string name="saved_devices_transfer_receive">Receive</string> <string name="saved_devices_transfer_receive">Receive</string>
<string name="saved_devices_transfer_resume">Resume</string> <string name="saved_devices_transfer_resume">Resume</string>
<string name="saved_devices_transfer_cancel">Cancel</string> <string name="saved_devices_transfer_cancel">Cancel</string>
@@ -341,7 +340,6 @@
<string name="saved_devices_authenticated_name">Remote name: %1$s</string> <string name="saved_devices_authenticated_name">Remote name: %1$s</string>
<string name="saved_devices_block_confirm_body">Block %1$s and reject future saved-device transfers?</string> <string name="saved_devices_block_confirm_body">Block %1$s and reject future saved-device transfers?</string>
<string name="saved_devices_block_confirm_title">Block device?</string> <string name="saved_devices_block_confirm_title">Block device?</string>
<string name="saved_devices_description">Send directly to devices you trust, without sharing another invitation.</string>
<string name="saved_devices_endpoint">Device ID: %1$s</string> <string name="saved_devices_endpoint">Device ID: %1$s</string>
<string name="saved_devices_empty">Finish a transfer, then choose to remember the other device.</string> <string name="saved_devices_empty">Finish a transfer, then choose to remember the other device.</string>
<string name="saved_devices_empty_title">No saved devices</string> <string name="saved_devices_empty_title">No saved devices</string>

View File

@@ -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<SavedDeviceTransferItem>,
busy: Boolean,
busyTransferIds: Set<String>,
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<DeviceDestructiveAction?>(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)) }
},
)
}
}

View File

@@ -1,5 +1,6 @@
package com.vnidrop.app.feature.saveddevices package com.vnidrop.app.feature.saveddevices
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag 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 com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res 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.button_retry
import vnidrop.shared.generated.resources.offer_accept import vnidrop.shared.generated.resources.offer_accept
import vnidrop.shared.generated.resources.offer_body 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.offer_title
import vnidrop.shared.generated.resources.saved_devices_accept_pairing_action 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_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_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_eligibility_title
import vnidrop.shared.generated.resources.saved_devices_empty import vnidrop.shared.generated.resources.saved_devices_empty
import vnidrop.shared.generated.resources.saved_devices_empty_title 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_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_incoming
import vnidrop.shared.generated.resources.saved_devices_pending_outgoing 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_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 import vnidrop.shared.generated.resources.saved_devices_unnamed
@Composable @Composable
@@ -94,45 +71,21 @@ internal fun CompactSavedDevicesHub(
onDeclineIncoming: (String) -> Unit, onDeclineIncoming: (String) -> Unit,
onAcceptOffer: (String) -> Unit, onAcceptOffer: (String) -> Unit,
onDeclineOffer: (String) -> Unit, onDeclineOffer: (String) -> Unit,
onSend: (String) -> Unit, onOpenDevice: (String) -> Unit,
onOpenLabel: (String) -> Unit,
onForget: (String) -> Unit,
onBlock: (String) -> Unit,
onTransferAction: (String, SavedDeviceTransferAction) -> Unit,
) { ) {
val hasAttention = state.attentionCount > 0 SavedDevicesHub(
val isEmpty = !hasAttention && state.savedDevices.isEmpty() && state.targetedTransfers.isEmpty() state = state,
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 28.dp), contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 28.dp),
verticalArrangement = Arrangement.spacedBy(20.dp), onRetry = onRetry,
) { onRememberEligible = onRememberEligible,
if (state.isLoading) item(key = "loading") { androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth()) } onDeclineEligible = onDeclineEligible,
if (state.loadFailed) item(key = "load-failed") { InlineLoadFailure(onRetry) } onAcceptIncoming = onAcceptIncoming,
if (hasAttention) { onDeclineIncoming = onDeclineIncoming,
item(key = "attention") { onAcceptOffer = onAcceptOffer,
AttentionSection( onDeclineOffer = onDeclineOffer,
state, onOpenDevice = onOpenDevice,
onRememberEligible,
onDeclineEligible,
onAcceptIncoming,
onDeclineIncoming,
onAcceptOffer,
onDeclineOffer,
) )
} }
}
if (state.savedDevices.isNotEmpty()) {
item(key = "saved-devices") {
SavedDeviceSection(state, onSend, onOpenLabel, onForget, onBlock)
}
}
if (state.targetedTransfers.isNotEmpty()) {
targetedTransferItems(state.targetedTransfers, state.busyTransferIds, onTransferAction)
}
if (isEmpty) item(key = "empty") { SavedDevicesEmptyState(Modifier.fillParentMaxHeight(0.72f)) }
}
}
@Composable @Composable
internal fun DesktopSavedDevicesHub( internal fun DesktopSavedDevicesHub(
@@ -144,30 +97,49 @@ internal fun DesktopSavedDevicesHub(
onDeclineIncoming: (String) -> Unit, onDeclineIncoming: (String) -> Unit,
onAcceptOffer: (String) -> Unit, onAcceptOffer: (String) -> Unit,
onDeclineOffer: (String) -> Unit, onDeclineOffer: (String) -> Unit,
onSend: (String) -> Unit, onOpenDevice: (String) -> Unit,
onOpenLabel: (String) -> Unit, ) {
onForget: (String) -> Unit, Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
onBlock: (String) -> Unit, SavedDevicesHub(
onTransferAction: (String, SavedDeviceTransferAction) -> Unit, 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 hasAttention = state.attentionCount > 0
val isEmpty = !hasAttention && state.savedDevices.isEmpty() && state.targetedTransfers.isEmpty() val showEmptyState = !hasAttention && state.savedDevices.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( LazyColumn(
modifier = Modifier.weight(0.9f).fillMaxHeight(), modifier = modifier.fillMaxSize(),
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(20.dp), verticalArrangement = Arrangement.spacedBy(20.dp),
contentPadding = PaddingValues(bottom = 24.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 (state.loadFailed) item(key = "load-failed") { InlineLoadFailure(onRetry) }
if (hasAttention) { if (hasAttention) {
item(key = "attention") { item(key = "attention") {
@@ -183,33 +155,10 @@ internal fun DesktopSavedDevicesHub(
} }
} }
if (state.savedDevices.isNotEmpty()) { if (state.savedDevices.isNotEmpty()) {
item(key = "saved-devices") { item(key = "saved-devices") { SavedDeviceSection(state, onOpenDevice) }
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)) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
SectionTitle(stringResource(Res.string.saved_devices_attention_title)) SectionTitle(stringResource(Res.string.saved_devices_attention_title))
Surface( Surface(shape = RoundedCornerShape(12.dp), color = LocalVniDropColors.current.backgroundSurface200) {
shape = RoundedCornerShape(12.dp),
color = LocalVniDropColors.current.backgroundSurface200,
) {
Column { Column {
var hasPrevious = false var hasPrevious = false
state.targetedOffers.pending.forEach { offer -> state.targetedOffers.pending.forEach { offer ->
@@ -285,7 +231,6 @@ private fun TargetedOfferRow(
icon = AppIcon.Download, icon = AppIcon.Download,
title = stringResource(Res.string.offer_title), title = stringResource(Res.string.offer_title),
body = stringResource(Res.string.offer_body, deviceName, offer.transferName), body = stringResource(Res.string.offer_body, deviceName, offer.transferName),
endpointId = offer.senderEndpointId,
busy = busy, busy = busy,
) { ) {
PrimaryButton(stringResource(Res.string.offer_accept), onAccept, enabled = !busy) PrimaryButton(stringResource(Res.string.offer_accept), onAccept, enabled = !busy)
@@ -301,10 +246,9 @@ private fun EligibilityRow(
onDecline: () -> Unit, onDecline: () -> Unit,
) { ) {
DecisionRow( DecisionRow(
icon = AppIcon.Shield, icon = AppIcon.Device,
title = eligibility.remoteDisplayName?.takeIf(String::isNotBlank) ?: stringResource(Res.string.saved_devices_unnamed), title = eligibility.remoteDisplayName?.takeIf(String::isNotBlank) ?: stringResource(Res.string.saved_devices_unnamed),
body = stringResource(Res.string.saved_devices_eligibility_title), body = stringResource(Res.string.saved_devices_eligibility_title),
endpointId = eligibility.peerEndpointId,
busy = busy, busy = busy,
) { ) {
PrimaryButton(stringResource(Res.string.saved_devices_remember_action), onRemember, enabled = !busy) PrimaryButton(stringResource(Res.string.saved_devices_remember_action), onRemember, enabled = !busy)
@@ -321,7 +265,7 @@ private fun PendingPairingRow(
onDecline: () -> Unit, onDecline: () -> Unit,
) { ) {
DecisionRow( DecisionRow(
icon = AppIcon.Shield, icon = AppIcon.Device,
title = remoteDisplayName?.takeIf(String::isNotBlank) ?: stringResource(Res.string.saved_devices_unnamed), title = remoteDisplayName?.takeIf(String::isNotBlank) ?: stringResource(Res.string.saved_devices_unnamed),
body = stringResource( body = stringResource(
if (relationship.state == DeviceRelationshipStateModel.PendingIncoming) { if (relationship.state == DeviceRelationshipStateModel.PendingIncoming) {
@@ -330,7 +274,6 @@ private fun PendingPairingRow(
Res.string.saved_devices_pending_outgoing Res.string.saved_devices_pending_outgoing
}, },
), ),
endpointId = relationship.remoteEndpointId,
busy = busy, busy = busy,
actions = if (relationship.state == DeviceRelationshipStateModel.PendingIncoming) { actions = if (relationship.state == DeviceRelationshipStateModel.PendingIncoming) {
{ {
@@ -348,18 +291,16 @@ private fun DecisionRow(
icon: AppIcon, icon: AppIcon,
title: String, title: String,
body: String, body: String,
endpointId: String,
busy: Boolean, busy: Boolean,
actions: (@Composable RowScope.() -> Unit)?, actions: (@Composable RowScope.() -> Unit)?,
) { ) {
val colors = LocalVniDropColors.current val colors = LocalVniDropColors.current
Column(Modifier.padding(horizontal = 16.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(Modifier.padding(horizontal = 16.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(12.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)) { Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Text(body, style = MaterialTheme.typography.bodyMedium, color = colors.foregroundLight) Text(body, style = MaterialTheme.typography.bodyMedium, color = colors.foregroundLight)
DiagnosticEndpoint(endpointId)
} }
if (busy) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) if (busy) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
} }
@@ -368,108 +309,43 @@ private fun DecisionRow(
} }
@Composable @Composable
private fun SavedDeviceSection( private fun SavedDeviceSection(state: SavedDevicesState, onOpenDevice: (String) -> Unit) {
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 { Column {
state.savedDevices.forEachIndexed { index, device -> state.savedDevices.forEachIndexed { index, device ->
if (index > 0) GroupDivider() if (index > 0) GroupDivider()
SavedDeviceRow( SavedDeviceRow(
device = device, device = device,
busy = device.endpointId in state.busyPeerIds, busy = device.endpointId in state.busyPeerIds,
onSend = { onSend(device.endpointId) }, onOpen = { onOpenDevice(device.endpointId) },
onLabel = { onOpenLabel(device.endpointId) },
onForget = { onForget(device.endpointId) },
onBlock = { onBlock(device.endpointId) },
) )
} }
} }
} }
}
}
private enum class DeviceDestructiveAction { Forget, Block }
@Composable @Composable
private fun SavedDeviceRow( private fun SavedDeviceRow(device: SavedDeviceModel, busy: Boolean, onOpen: () -> Unit) {
device: SavedDeviceModel,
busy: Boolean,
onSend: () -> Unit,
onLabel: () -> Unit,
onForget: () -> Unit,
onBlock: () -> Unit,
) {
val colors = LocalVniDropColors.current val colors = LocalVniDropColors.current
val title = device.displayName()
var menuExpanded by remember(device.endpointId) { mutableStateOf(false) }
var pendingAction by remember(device.endpointId) { mutableStateOf<DeviceDestructiveAction?>(null) }
Row( 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, 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)) Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(
device.remoteDisplayName device.displayName(),
?.takeIf { device.localLabel?.isNotBlank() == true && it.isNotBlank() } style = MaterialTheme.typography.titleSmall,
?.let { Text(stringResource(Res.string.saved_devices_authenticated_name, it), style = MaterialTheme.typography.bodySmall, color = colors.foregroundLight) } fontWeight = FontWeight.SemiBold,
DiagnosticEndpoint(device.endpointId) 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) },
) )
} }
} if (busy) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
} else PlatformIcon(AppIcon.ChevronRight, contentDescription = null, tint = colors.foregroundLighter)
}
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)) } },
)
} }
} }
@@ -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 @Composable
private fun SavedDevicesEmptyState(modifier: Modifier = Modifier) { private fun SavedDevicesEmptyState(modifier: Modifier = Modifier) {
val colors = LocalVniDropColors.current val colors = LocalVniDropColors.current
@@ -511,7 +376,7 @@ private fun SavedDevicesEmptyState(modifier: Modifier = Modifier) {
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp), 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_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text( Text(
stringResource(Res.string.saved_devices_empty), stringResource(Res.string.saved_devices_empty),
@@ -536,9 +401,9 @@ private fun InlineLoadFailure(onRetry: () -> Unit) {
} }
@Composable @Composable
private fun SavedDeviceModel.displayName(): String = localLabel?.takeIf(String::isNotBlank) internal fun SavedDeviceModel.displayName(): String = localLabel?.takeIf(String::isNotBlank)
?: remoteDisplayName?.takeIf(String::isNotBlank) ?: remoteDisplayName?.takeIf(String::isNotBlank)
?: stringResource(Res.string.saved_devices_unnamed) ?: 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) + "" if (endpointId.length <= 20) endpointId else endpointId.take(16) + ""

View File

@@ -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_receive
import vnidrop.shared.generated.resources.saved_devices_transfer_resume 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_transfers_title
import vnidrop.shared.generated.resources.saved_devices_endpoint
import vnidrop.shared.generated.resources.saved_devices_unnamed import vnidrop.shared.generated.resources.saved_devices_unnamed
import vnidrop.shared.generated.resources.status_approved import vnidrop.shared.generated.resources.status_approved
import vnidrop.shared.generated.resources.status_awaiting_approval import vnidrop.shared.generated.resources.status_awaiting_approval
@@ -63,17 +62,13 @@ enum class SavedDeviceTransferAction {
Delete, Delete,
} }
internal enum class TargetedTransferPresentation {
CompactRow,
DesktopRow,
}
internal fun LazyListScope.targetedTransferItems( internal fun LazyListScope.targetedTransferItems(
transfers: List<SavedDeviceTransferItem>, transfers: List<SavedDeviceTransferItem>,
busyTransferIds: Set<String>, busyTransferIds: Set<String>,
onAction: (String, SavedDeviceTransferAction) -> Unit, onAction: (String, SavedDeviceTransferAction) -> Unit,
presentation: TargetedTransferPresentation = TargetedTransferPresentation.CompactRow, showHeading: Boolean = true,
) { ) {
if (showHeading) {
item(key = "targeted-transfer-title") { item(key = "targeted-transfer-title") {
Text( Text(
text = stringResource(Res.string.saved_devices_transfers_title), text = stringResource(Res.string.saved_devices_transfers_title),
@@ -82,6 +77,7 @@ internal fun LazyListScope.targetedTransferItems(
modifier = Modifier.padding(top = 8.dp).semantics { heading() }, modifier = Modifier.padding(top = 8.dp).semantics { heading() },
) )
} }
}
if (transfers.isNotEmpty()) { if (transfers.isNotEmpty()) {
itemsIndexed(transfers, key = { _, transfer -> "targeted-${transfer.id}" }) { index, transfer -> itemsIndexed(transfers, key = { _, transfer -> "targeted-${transfer.id}" }) { index, transfer ->
if (index > 0) { if (index > 0) {
@@ -90,7 +86,6 @@ internal fun LazyListScope.targetedTransferItems(
TargetedTransferRow( TargetedTransferRow(
transfer = transfer, transfer = transfer,
busy = transfer.id in busyTransferIds, busy = transfer.id in busyTransferIds,
presentation = presentation,
onAction = { action -> onAction(transfer.id, action) }, onAction = { action -> onAction(transfer.id, action) },
) )
} }
@@ -101,7 +96,6 @@ internal fun LazyListScope.targetedTransferItems(
private fun TargetedTransferRow( private fun TargetedTransferRow(
transfer: SavedDeviceTransferItem, transfer: SavedDeviceTransferItem,
busy: Boolean, busy: Boolean,
presentation: TargetedTransferPresentation,
onAction: (SavedDeviceTransferAction) -> Unit, onAction: (SavedDeviceTransferAction) -> Unit,
) { ) {
val colors = LocalVniDropColors.current val colors = LocalVniDropColors.current
@@ -109,10 +103,7 @@ private fun TargetedTransferRow(
(transfer.verifiedBytes.toDouble() / transfer.totalSize.toDouble()).coerceIn(0.0, 1.0).toFloat() (transfer.verifiedBytes.toDouble() / transfer.totalSize.toDouble()).coerceIn(0.0, 1.0).toFloat()
} }
Column( Column(
Modifier.fillMaxWidth().padding( Modifier.fillMaxWidth().padding(vertical = 14.dp),
horizontal = if (presentation == TargetedTransferPresentation.DesktopRow) 4.dp else 0.dp,
vertical = 14.dp,
),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -163,27 +154,6 @@ private fun TargetedTransferRow(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = colors.foregroundLighter, 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 ( if (
transfer.direction == SavedDeviceTransferDirection.Incoming && transfer.direction == SavedDeviceTransferDirection.Incoming &&
@@ -282,6 +252,3 @@ private fun TargetedTransferStateModel.isDeletable(): Boolean = this in setOf(
TargetedTransferStateModel.Cancelled, TargetedTransferStateModel.Cancelled,
TargetedTransferStateModel.Failed, TargetedTransferStateModel.Failed,
) )
private fun shortTransferEndpoint(endpointId: String): String =
if (endpointId.length <= 20) endpointId else endpointId.take(16) + ""

View File

@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -17,6 +18,10 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.heading 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.Res
import vnidrop.shared.generated.resources.button_cancel import vnidrop.shared.generated.resources.button_cancel
import vnidrop.shared.generated.resources.button_retry 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_clear
import vnidrop.shared.generated.resources.saved_devices_label_placeholder import vnidrop.shared.generated.resources.saved_devices_label_placeholder
import vnidrop.shared.generated.resources.saved_devices_label_save import vnidrop.shared.generated.resources.saved_devices_label_save
@@ -63,14 +67,21 @@ internal fun SavedDevicesScreen(
onClearLabel: () -> Unit, onClearLabel: () -> Unit,
onDismissLabel: () -> Unit, onDismissLabel: () -> Unit,
) { ) {
var selectedDeviceId by remember { mutableStateOf<String?>(null) }
val selectedDevice = state.savedDevices.firstOrNull { it.endpointId == selectedDeviceId }
val hasContent = state.eligibilities.isNotEmpty() || state.pendingRelationships.isNotEmpty() || val hasContent = state.eligibilities.isNotEmpty() || state.pendingRelationships.isNotEmpty() ||
state.savedDevices.isNotEmpty() || state.targetedTransfers.isNotEmpty() || state.savedDevices.isNotEmpty() || state.targetedTransfers.isNotEmpty() ||
state.targetedOffers.pending.isNotEmpty() state.targetedOffers.pending.isNotEmpty()
Column( Column(
modifier = modifier.fillMaxSize().statusBarsPadding().padding(top = 16.dp), modifier = modifier.fillMaxSize().statusBarsPadding().padding(top = 16.dp),
) { ) {
SavedDevicesHeader(Modifier.padding(horizontal = if (windowClass == WindowClass.Desktop) 24.dp else 16.dp)) Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Spacer(Modifier.height(20.dp)) SavedDevicesHeader(
Modifier.fillMaxWidth().widthIn(max = 720.dp)
.padding(horizontal = if (windowClass == WindowClass.Desktop) 24.dp else 16.dp),
)
}
Spacer(Modifier.height(14.dp))
when { when {
state.isLoading && !hasContent -> SavedDevicesLoading(Modifier.weight(1f)) state.isLoading && !hasContent -> SavedDevicesLoading(Modifier.weight(1f))
state.loadFailed && !hasContent -> SavedDevicesLoadFailure(onRetry, Modifier.weight(1f)) state.loadFailed && !hasContent -> SavedDevicesLoadFailure(onRetry, Modifier.weight(1f))
@@ -83,11 +94,7 @@ internal fun SavedDevicesScreen(
onDeclineIncoming = onDeclineIncoming, onDeclineIncoming = onDeclineIncoming,
onAcceptOffer = onAcceptOffer, onAcceptOffer = onAcceptOffer,
onDeclineOffer = onDeclineOffer, onDeclineOffer = onDeclineOffer,
onSend = onSend, onOpenDevice = { selectedDeviceId = it },
onOpenLabel = onOpenLabel,
onForget = onForget,
onBlock = onBlock,
onTransferAction = onTransferAction,
) )
else -> CompactSavedDevicesHub( else -> CompactSavedDevicesHub(
state = state, state = state,
@@ -98,17 +105,32 @@ internal fun SavedDevicesScreen(
onDeclineIncoming = onDeclineIncoming, onDeclineIncoming = onDeclineIncoming,
onAcceptOffer = onAcceptOffer, onAcceptOffer = onAcceptOffer,
onDeclineOffer = onDeclineOffer, onDeclineOffer = onDeclineOffer,
onSend = onSend, onOpenDevice = { selectedDeviceId = it },
onOpenLabel = onOpenLabel,
onForget = onForget,
onBlock = onBlock,
onTransferAction = onTransferAction,
) )
} }
} }
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( SavedDeviceLabelDialog(
visible = state.labelingPeerId != null, visible = state.labelingPeerId != null,
label = state.labelDraft, label = state.labelDraft,
saving = state.isSavingLabel,
onLabelChanged = onLabelDraftChanged, onLabelChanged = onLabelDraftChanged,
onSave = onSaveLabel, onSave = onSaveLabel,
onClear = onClearLabel, onClear = onClearLabel,
@@ -118,19 +140,12 @@ internal fun SavedDevicesScreen(
@Composable @Composable
private fun SavedDevicesHeader(modifier: Modifier = Modifier) { private fun SavedDevicesHeader(modifier: Modifier = Modifier) {
Column(modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text( Text(
text = stringResource(Res.string.saved_devices_list_title), text = stringResource(Res.string.saved_devices_list_title),
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.semantics { heading() }, modifier = modifier.semantics { heading() },
) )
Text(
text = stringResource(Res.string.saved_devices_description),
style = MaterialTheme.typography.bodyMedium,
color = LocalVniDropColors.current.foregroundLight,
)
}
} }
@Composable @Composable
@@ -158,6 +173,7 @@ private fun SavedDevicesLoadFailure(onRetry: () -> Unit, modifier: Modifier = Mo
private fun SavedDeviceLabelDialog( private fun SavedDeviceLabelDialog(
visible: Boolean, visible: Boolean,
label: String, label: String,
saving: Boolean,
onLabelChanged: (String) -> Unit, onLabelChanged: (String) -> Unit,
onSave: () -> Unit, onSave: () -> Unit,
onClear: () -> Unit, onClear: () -> Unit,
@@ -165,24 +181,28 @@ private fun SavedDeviceLabelDialog(
) { ) {
if (!visible) return if (!visible) return
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = { if (!saving) onDismiss() },
title = { Text(stringResource(Res.string.saved_devices_label_title)) }, title = { Text(stringResource(Res.string.saved_devices_label_title)) },
text = { text = {
OutlinedTextField( OutlinedTextField(
value = label, value = label,
onValueChange = onLabelChanged, onValueChange = onLabelChanged,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
enabled = !saving,
singleLine = true, singleLine = true,
placeholder = { Text(stringResource(Res.string.saved_devices_label_placeholder)) }, placeholder = { Text(stringResource(Res.string.saved_devices_label_placeholder)) },
) )
}, },
confirmButton = { 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 = { dismissButton = {
androidx.compose.foundation.layout.Row { androidx.compose.foundation.layout.Row {
TextButton(onClick = onClear) { Text(stringResource(Res.string.saved_devices_label_clear)) } TextButton(onClick = onClear, enabled = !saving) { Text(stringResource(Res.string.saved_devices_label_clear)) }
TextButton(onClick = onDismiss) { Text(stringResource(Res.string.button_cancel)) } TextButton(onClick = onDismiss, enabled = !saving) { Text(stringResource(Res.string.button_cancel)) }
} }
}, },
) )

View File

@@ -48,6 +48,7 @@ data class SavedDevicesState(
val busyTransferIds: Set<String> = emptySet(), val busyTransferIds: Set<String> = emptySet(),
val labelingPeerId: String? = null, val labelingPeerId: String? = null,
val labelDraft: String = "", val labelDraft: String = "",
val isSavingLabel: Boolean = false,
) )
/** /**
@@ -166,39 +167,53 @@ class SavedDevicesViewModel(
} }
fun openLabelEditor(peerEndpointId: String) { fun openLabelEditor(peerEndpointId: String) {
if (_state.value.isSavingLabel || peerEndpointId in _state.value.busyPeerIds) return
val current = _state.value.savedDevices.firstOrNull { it.endpointId == peerEndpointId } val current = _state.value.savedDevices.firstOrNull { it.endpointId == peerEndpointId }
_state.update { _state.update {
it.copy(labelingPeerId = peerEndpointId, labelDraft = current?.localLabel.orEmpty()) 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() { fun dismissLabelEditor() {
if (_state.value.labelingPeerId !in _state.value.busyPeerIds) { if (!_state.value.isSavingLabel) {
_state.update { it.copy(labelingPeerId = null, labelDraft = "") } _state.update { it.copy(labelingPeerId = null, labelDraft = "") }
} }
} }
fun saveLabel() { fun saveLabel() {
val peerId = _state.value.labelingPeerId ?: return commitLabel(_state.value.labelDraft.trim().ifBlank { null })
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 = "") }
} }
fun clearLabel() { fun clearLabel() {
commitLabel(null)
}
private fun commitLabel(label: String?) {
val peerId = _state.value.labelingPeerId ?: return val peerId = _state.value.labelingPeerId ?: return
mutatePeer(peerId) { if (_state.value.isSavingLabel || peerId in _state.value.busyPeerIds) return
repository.setSavedDeviceLabel(peerId, null).onSuccess { _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)) 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) { fun forget(peerEndpointId: String) = mutatePeer(peerEndpointId) {

View File

@@ -30,6 +30,7 @@ internal enum class AppIcon(
Code(Res.drawable.icon_material_code, Res.drawable.icon_fluent_code, Res.drawable.icon_lucide_code), 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), 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), 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), 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), 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), File(Res.drawable.icon_material_file, Res.drawable.icon_fluent_file, Res.drawable.icon_lucide_file),

View File

@@ -24,6 +24,6 @@ internal data class NavigationItem(
internal val primaryNavigationItems = listOf( internal val primaryNavigationItems = listOf(
NavigationItem(AppDestination.Send, Res.string.nav_send, AppIcon.Send), NavigationItem(AppDestination.Send, Res.string.nav_send, AppIcon.Send),
NavigationItem(AppDestination.Receive, Res.string.nav_receive, AppIcon.Download), 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), NavigationItem(AppDestination.Settings, Res.string.nav_settings, AppIcon.Settings),
) )

View File

@@ -19,6 +19,7 @@ import com.vnidrop.app.ui.feedback.UiMessageController
import com.vnidrop.app.ui.theme.ThemeMode import com.vnidrop.app.ui.theme.ThemeMode
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.resetMain
@@ -228,6 +229,55 @@ class SavedDevicesViewModelTest {
assertEquals(listOf("peer-2"), core.blockedPeers) 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<Unit>()
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( private fun createViewModel(
core: FakeCoreGateway, core: FakeCoreGateway,
fileSystemService: FakeFileSystemService = FakeFileSystemService(receiveFolder()), fileSystemService: FakeFileSystemService = FakeFileSystemService(receiveFolder()),

View File

@@ -221,6 +221,8 @@ class FakeCoreGateway : CoreGateway {
val forgottenDevices = mutableListOf<String>() val forgottenDevices = mutableListOf<String>()
val blockedPeers = mutableListOf<String>() val blockedPeers = mutableListOf<String>()
val labeledDevices = mutableListOf<Pair<String, String?>>() val labeledDevices = mutableListOf<Pair<String, String?>>()
var beforeSetSavedDeviceLabel: suspend () -> Unit = {}
var setSavedDeviceLabelResult: Result<Unit> = Result.success(Unit)
override suspend fun listPairingEligibilities(): Result<List<PairingEligibilityModel>> { override suspend fun listPairingEligibilities(): Result<List<PairingEligibilityModel>> {
listPairingEligibilitiesCount += 1 listPairingEligibilitiesCount += 1
@@ -262,11 +264,13 @@ class FakeCoreGateway : CoreGateway {
return Result.success(savedDevices) return Result.success(savedDevices)
} }
override suspend fun setSavedDeviceLabel(peerEndpointId: String, label: String?): Result<Unit> { override suspend fun setSavedDeviceLabel(peerEndpointId: String, label: String?): Result<Unit> {
beforeSetSavedDeviceLabel()
labeledDevices += peerEndpointId to label labeledDevices += peerEndpointId to label
if (setSavedDeviceLabelResult.isFailure) return setSavedDeviceLabelResult
savedDevices = savedDevices.map { savedDevices = savedDevices.map {
if (it.endpointId == peerEndpointId) it.copy(localLabel = label) else it if (it.endpointId == peerEndpointId) it.copy(localLabel = label) else it
} }
return Result.success(Unit) return setSavedDeviceLabelResult
} }
override suspend fun forgetSavedDevice(peerEndpointId: String): Result<Unit> { override suspend fun forgetSavedDevice(peerEndpointId: String): Result<Unit> {
forgottenDevices += peerEndpointId forgottenDevices += peerEndpointId

View File

@@ -6,8 +6,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertCountEquals 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.onAllNodesWithText
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText 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_action
import vnidrop.shared.generated.resources.saved_devices_block_confirm_title 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_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
import vnidrop.shared.generated.resources.saved_devices_empty_title import vnidrop.shared.generated.resources.saved_devices_empty_title
import vnidrop.shared.generated.resources.saved_devices_forget_action import vnidrop.shared.generated.resources.saved_devices_forget_action
@@ -64,7 +64,6 @@ class SavedDevicesScreenTest {
runOnIdle { state.value = SavedDevicesState(isLoading = false) } runOnIdle { state.value = SavedDevicesState(isLoading = false) }
onNodeWithText(Res.string.saved_devices_empty_title.value).assertIsDisplayed() onNodeWithText(Res.string.saved_devices_empty_title.value).assertIsDisplayed()
onNodeWithText(Res.string.saved_devices_empty.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) onAllNodesWithText(Res.string.saved_devices_transfers_title.value).assertCountEquals(0)
} }
@@ -87,8 +86,10 @@ class SavedDevicesScreenTest {
} }
onNodeWithText("Office laptop").assertIsDisplayed() 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) 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() 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()) } 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() onNodeWithContentDescription("More actions for Riley's phone").performClick()
onNodeWithText(Res.string.saved_devices_block_action.value).performClick() onNodeWithText(Res.string.saved_devices_block_action.value).performClick()
onNodeWithText(Res.string.saved_devices_block_confirm_title.value).assertIsDisplayed() 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() onNodeWithContentDescription("More actions for Desktop").performClick()
onNodeWithText(Res.string.saved_devices_forget_action.value).assertIsDisplayed() onNodeWithText(Res.string.saved_devices_forget_action.value).assertIsDisplayed()
} }
@Test @Test
fun directTransferHistoryShowsProgressAndRoutesResumeById() = runComposeUiTest { fun deviceTransfersStayHiddenUntilTheDeviceDetailsAreOpened() = runComposeUiTest {
val actions = mutableListOf<Pair<String, SavedDeviceTransferAction>>() val actions = mutableListOf<Pair<String, SavedDeviceTransferAction>>()
setContent { setContent {
CompositionLocalProvider(LocalUiPlatform provides UiPlatform.Android) { CompositionLocalProvider(LocalUiPlatform provides UiPlatform.Android) {
@@ -146,6 +149,7 @@ class SavedDevicesScreenTest {
SavedDevicesScreen( SavedDevicesScreen(
state = SavedDevicesState( state = SavedDevicesState(
isLoading = false, isLoading = false,
savedDevices = listOf(device("peer", null, "Office PC")),
targetedTransfers = listOf( targetedTransfers = listOf(
SavedDeviceTransferItem( SavedDeviceTransferItem(
id = "transfer-resume", 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(Res.string.saved_devices_transfers_title.value).assertIsDisplayed()
onNodeWithText("Project files").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() onNodeWithText(Res.string.saved_devices_transfer_resume.value).performClick()
runOnIdle { runOnIdle {
assertEquals(listOf("transfer-resume" to SavedDeviceTransferAction.Resume), actions) 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(Res.string.saved_devices_attention_title.value).assertIsDisplayed()
onNodeWithText("Amira's phone").assertIsDisplayed() onNodeWithText("Amira's phone").assertIsDisplayed()
onNodeWithText("Studio PC").assertIsDisplayed() onNodeWithText("Studio PC").assertIsDisplayed()
@@ -232,7 +238,7 @@ class SavedDevicesScreenTest {
val attentionTop = onNodeWithText(Res.string.saved_devices_attention_title.value) val attentionTop = onNodeWithText(Res.string.saved_devices_attention_title.value)
.fetchSemanticsNode().boundsInRoot.top .fetchSemanticsNode().boundsInRoot.top
val devicesTop = onNodeWithText(Res.string.saved_devices_devices_title.value) val devicesTop = onNodeWithTag("saved-device-peer-one")
.fetchSemanticsNode().boundsInRoot.top .fetchSemanticsNode().boundsInRoot.top
assertTrue(attentionTop < devicesTop, "compact layouts should surface pending decisions before the device list") assertTrue(attentionTop < devicesTop, "compact layouts should surface pending decisions before the device list")
} }

View File

@@ -39,6 +39,7 @@ ICONS = (
IconSource("code", "code", "Code", "code", "code-xml"), IconSource("code", "code", "Code", "code", "code-xml"),
IconSource("cloud_off", "cloud_off", "Cloud Off", "cloud_off", "cloud-off"), IconSource("cloud_off", "cloud_off", "Cloud Off", "cloud_off", "cloud-off"),
IconSource("delete", "delete", "Delete", "delete", "trash-2"), IconSource("delete", "delete", "Delete", "delete", "trash-2"),
IconSource("device", "devices", "Desktop", "desktop", "monitor"),
IconSource("document", "description", "Document", "document", "file-text"), IconSource("document", "description", "Document", "document", "file-text"),
IconSource("download", "download", "Arrow Download", "arrow_download", "download"), IconSource("download", "download", "Arrow Download", "arrow_download", "download"),
IconSource("file", "draft", "Document", "document", "file"), IconSource("file", "draft", "Document", "document", "file"),