feat(shared): graduate saved devices experience

This commit is contained in:
2026-08-13 00:53:16 +02:00
parent 6ab658fea2
commit ac77950ac3
31 changed files with 2023 additions and 1251 deletions

View File

@@ -1,6 +1,6 @@
--- ---
name: compose-skill name: compose-skill
description: VniDrop-specific Compose Multiplatform UI and Kotlin presentation architecture. Use when designing, implementing, refactoring, or reviewing code under shared/ for Android, Windows, or Linux: screens, ViewModels, routes, navigation, adaptive layouts, platform adapters, icons, resources, accessibility, and UI tests. description: VniDrop-specific Compose Multiplatform UI, Kotlin presentation architecture, and rendered-app visual QA. Use when designing, implementing, refactoring, or reviewing code under shared/ for Android, Windows, or Linux: screens, ViewModels, routes, navigation, adaptive layouts, platform adapters, native icons, resources, accessibility, UI tests, simulator inspection, screenshots, and visual refinement.
--- ---
# VniDrop KMP UI # VniDrop KMP UI
@@ -15,7 +15,9 @@ Build VniDrop's Android and desktop UI without weakening its domain model, platf
4. Identify the module, interface, seam, and adapters. Prefer a deep module: small interface, substantial hidden behavior, one test surface. 4. Identify the module, interface, seam, and adapters. Prefer a deep module: small interface, substantial hidden behavior, one test surface.
5. Model state and platform behavior before drawing pixels. 5. Model state and platform behavior before drawing pixels.
6. Implement the smallest complete product flow; add regressions at the lowest useful layer. 6. Implement the smallest complete product flow; add regressions at the lowest useful layer.
7. Run `make test-shared`; run `make check-shared` for a production UI handoff. 7. Run `make test-shared`, then launch and inspect the affected app using the visual QA gate below.
8. Refine the rendered result until every affected presentation passes the maturity and native-platform review.
9. Run `make check-shared` for a production UI handoff.
## Scope and ownership ## Scope and ownership
@@ -103,6 +105,40 @@ Use one deep, session-scoped composition module for Invitation and Targeted crea
- Preserve minimum touch targets, keyboard access, focus order, readable contrast, and meaningful semantics. - Preserve minimum touch targets, keyboard access, focus order, readable contrast, and meaningful semantics.
- Treat phone, tablet/rail, Windows desktop, and Linux desktop as deliberate presentations—not scaled copies. - Treat phone, tablet/rail, Windows desktop, and Linux desktop as deliberate presentations—not scaled copies.
### Visual maturity
Build quiet, intentional product interfaces. Establish hierarchy with typography, alignment, spacing, and native controls before adding containers or decoration.
- Give each screen one clear primary task and scanning order.
- Use cards only when a real object or boundary needs containment. Prefer native lists, grouped rows, dividers, and whitespace for ordinary collections.
- Use count badges only when the count changes a decision. Use icon tiles only when the icon is meaningful content or a native convention.
- Keep accent color scarce. Let status, selection, or the primary action earn it.
- Keep utility screens concise. Explanatory copy must resolve a real ambiguity; headings and helper panels are not filler.
- Preserve platform density: touch-friendly Material surfaces on Android and restrained, information-dense desktop layouts on Windows/Linux.
- Compare the result with the app's strongest nearby screen and the affected platform's native conventions. A prototype is input, not a visual specification to copy literally.
- Treat repeated rounded cards, pills, icon-in-a-square decoration, equal-weight sections, oversized headings, and generic dashboard layouts as signals to simplify.
## Rendered-app visual QA
A visible UI change is incomplete until the actual app has been launched and inspected. Unit tests, Compose tests, previews, and successful compilation do not replace this gate.
1. Build and launch the real affected host from the repository's current Make/Gradle tasks.
2. Navigate to the changed screen through the product UI. Exercise the changed interaction rather than stopping at app launch.
3. Inspect realistic content, including long names, empty/content states, busy or pending actions, and destructive confirmations when affected.
4. Capture a screenshot of every affected presentation and inspect hierarchy, density, alignment, clipping, contrast, native iconography, focus/touch targets, and awkward unused space.
5. Fix visible defects and repeat the same route. Complete the gate only after the new screenshot is materially acceptable.
Choose hosts by changed source set:
- `commonMain` visual changes: inspect an Android phone emulator and a desktop window when the UI has a desktop/adaptive branch.
- `androidMain`: inspect an Android emulator at the affected form factor.
- `jvmMain`: inspect the affected desktop presentation; verify Windows/Linux-specific conventions where those hosts are available.
- Logic-only ViewModel/model changes with no rendered difference may omit screenshots, but still require behavior tests.
Use available simulator or computer-control tools to operate the app and view the rendered result. Prefer screenshots from the running app over isolated previews. If an affected platform cannot be launched, report that exact validation gap and do not claim the UI is visually complete.
Apple UI lives under `apple/` and requires a native SwiftUI workflow with iOS/macOS simulator inspection. This Compose skill does not validate Apple presentation.
## Strings and resources ## Strings and resources
- `localization/strings.json` is the only source of truth for product strings. - `localization/strings.json` is the only source of truth for product strings.
@@ -129,6 +165,7 @@ Use one deep, session-scoped composition module for Invitation and Targeted crea
- Assert native icon-family selection and accessibility semantics when adding platform actions. - Assert native icon-family selection and accessibility semantics when adding platform actions.
- Prefer deterministic gates and virtual time; avoid fixed sleeps. - Prefer deterministic gates and virtual time; avoid fixed sleeps.
- Delete obsolete shallow tests after equivalent interface-level coverage exists. - Delete obsolete shallow tests after equivalent interface-level coverage exists.
- Record which real hosts and screen states were visually inspected in the handoff.
## Anti-patterns ## Anti-patterns

View File

@@ -1,4 +1,4 @@
interface: interface:
display_name: "VniDrop KMP UI" display_name: "VniDrop KMP UI"
short_description: "Build native-feeling VniDrop KMP interfaces" short_description: "Build and visually verify native VniDrop UI"
default_prompt: "Use $compose-skill to design or implement a native-feeling VniDrop KMP UI feature." default_prompt: "Use $compose-skill to design, implement, launch, and visually verify a native-feeling VniDrop KMP UI feature."

1
.gitignore vendored
View File

@@ -21,6 +21,7 @@ target/
.junie .junie
config.override.mk config.override.mk
bin/ bin/
tmp/
# Local design export scratch # Local design export scratch
output/ output/

View File

@@ -4828,6 +4828,185 @@
"ru": "Отклонить" "ru": "Отклонить"
} }
}, },
"saved_devices_attention_title": {
"context": "Saved devices screen: heading for transfers and pairing decisions that need a response.",
"translations": {
"en": "Needs attention",
"fr": "Nécessite votre attention",
"es": "Requiere atención",
"it": "Richiede attenzione",
"de": "Erfordert Aufmerksamkeit",
"pt": "Requer atenção",
"pl": "Wymaga uwagi",
"nl": "Aandacht vereist",
"ru": "Требует внимания"
},
"targets": ["kmp"]
},
"saved_devices_devices_title": {
"context": "Saved devices screen: heading for mutually trusted saved devices.",
"translations": {
"en": "Your devices",
"fr": "Vos appareils",
"es": "Tus dispositivos",
"it": "I tuoi dispositivi",
"de": "Deine Geräte",
"pt": "Os seus dispositivos",
"pl": "Twoje urządzenia",
"nl": "Je apparaten",
"ru": "Ваши устройства"
},
"targets": ["kmp"]
},
"saved_devices_transfers_title": {
"context": "Saved devices screen: heading for durable targeted-transfer history.",
"translations": {
"en": "Direct transfers",
"fr": "Transferts directs",
"es": "Transferencias directas",
"it": "Trasferimenti diretti",
"de": "Direkte Übertragungen",
"pt": "Transferências diretas",
"pl": "Transfery bezpośrednie",
"nl": "Directe overdrachten",
"ru": "Прямые передачи"
},
"targets": ["kmp"]
},
"saved_devices_transfer_empty": {
"context": "Saved devices screen: empty targeted-transfer history.",
"translations": {
"en": "No direct transfers yet.",
"fr": "Aucun transfert direct pour le moment.",
"es": "Aún no hay transferencias directas.",
"it": "Nessun trasferimento diretto.",
"de": "Noch keine direkten Übertragungen.",
"pt": "Ainda não existem transferências diretas.",
"pl": "Brak transferów bezpośrednich.",
"nl": "Nog geen directe overdrachten.",
"ru": "Прямых передач пока нет."
},
"targets": ["kmp"]
},
"saved_devices_transfer_direction_incoming": {
"context": "Targeted transfer card: incoming direction followed by the saved-device display name.",
"args": [{"name": "device", "type": "string"}],
"translations": {
"en": "From %1$s",
"fr": "De %1$s",
"es": "De %1$s",
"it": "Da %1$s",
"de": "Von %1$s",
"pt": "De %1$s",
"pl": "Od %1$s",
"nl": "Van %1$s",
"ru": "От %1$s"
},
"targets": ["kmp"]
},
"saved_devices_transfer_direction_outgoing": {
"context": "Targeted transfer card: outgoing direction followed by the saved-device display name.",
"args": [{"name": "device", "type": "string"}],
"translations": {
"en": "To %1$s",
"fr": "Vers %1$s",
"es": "A %1$s",
"it": "A %1$s",
"de": "An %1$s",
"pt": "Para %1$s",
"pl": "Do %1$s",
"nl": "Naar %1$s",
"ru": "Для %1$s"
},
"targets": ["kmp"]
},
"saved_devices_transfer_files": {
"context": "Targeted transfer card metadata. First placeholder is file count, second is formatted total size.",
"args": [{"name": "count", "type": "string"}, {"name": "size", "type": "string"}],
"translations": {
"en": "%1$s files · %2$s",
"fr": "%1$s fichiers · %2$s",
"es": "%1$s archivos · %2$s",
"it": "%1$s file · %2$s",
"de": "%1$s Dateien · %2$s",
"pt": "%1$s ficheiros · %2$s",
"pl": "%1$s plików · %2$s",
"nl": "%1$s bestanden · %2$s",
"ru": "%1$s файлов · %2$s"
},
"targets": ["kmp"]
},
"saved_devices_transfer_progress": {
"context": "Targeted transfer card progress. First placeholder is verified bytes, second is total size.",
"args": [{"name": "verified", "type": "string"}, {"name": "total", "type": "string"}],
"translations": {
"en": "%1$s of %2$s",
"fr": "%1$s sur %2$s",
"es": "%1$s de %2$s",
"it": "%1$s di %2$s",
"de": "%1$s von %2$s",
"pt": "%1$s de %2$s",
"pl": "%1$s z %2$s",
"nl": "%1$s van %2$s",
"ru": "%1$s из %2$s"
},
"targets": ["kmp"]
},
"saved_devices_transfer_receive": {
"context": "Targeted transfer history action: receive an approved transfer.",
"translations": {"en": "Receive", "fr": "Recevoir", "es": "Recibir", "it": "Ricevi", "de": "Empfangen", "pt": "Receber", "pl": "Odbierz", "nl": "Ontvangen", "ru": "Получить"},
"targets": ["kmp"]
},
"saved_devices_transfer_resume": {
"context": "Targeted transfer history action: resume an interrupted transfer.",
"translations": {"en": "Resume", "fr": "Reprendre", "es": "Reanudar", "it": "Riprendi", "de": "Fortsetzen", "pt": "Retomar", "pl": "Wznów", "nl": "Hervatten", "ru": "Продолжить"},
"targets": ["kmp"]
},
"saved_devices_transfer_cancel": {
"context": "Targeted transfer history action: cancel an active transfer.",
"translations": {"en": "Cancel", "fr": "Annuler", "es": "Cancelar", "it": "Annulla", "de": "Abbrechen", "pt": "Cancelar", "pl": "Anuluj", "nl": "Annuleren", "ru": "Отменить"},
"targets": ["kmp"]
},
"saved_devices_transfer_delete": {
"context": "Targeted transfer history action: delete a terminal transfer record.",
"translations": {"en": "Delete", "fr": "Supprimer", "es": "Eliminar", "it": "Elimina", "de": "Löschen", "pt": "Eliminar", "pl": "Usuń", "nl": "Verwijderen", "ru": "Удалить"},
"targets": ["kmp"]
},
"status_offering": {
"context": "Targeted transfer status: offering to the saved device.",
"translations": {"en": "Offering", "fr": "Proposition", "es": "Ofreciendo", "it": "Proposta", "de": "Wird angeboten", "pt": "A propor", "pl": "Oferowanie", "nl": "Aanbieden", "ru": "Предложение"},
"targets": ["kmp"]
},
"status_awaiting_approval": {
"context": "Targeted transfer status: waiting for the saved device to approve.",
"translations": {"en": "Waiting for approval", "fr": "En attente dapprobation", "es": "Esperando aprobación", "it": "In attesa di approvazione", "de": "Wartet auf Genehmigung", "pt": "A aguardar aprovação", "pl": "Oczekiwanie na zgodę", "nl": "Wachten op goedkeuring", "ru": "Ожидание подтверждения"},
"targets": ["kmp"]
},
"status_approved": {
"context": "Targeted transfer status: approved by the receiving saved device.",
"translations": {"en": "Approved", "fr": "Approuvé", "es": "Aprobado", "it": "Approvato", "de": "Genehmigt", "pt": "Aprovado", "pl": "Zatwierdzony", "nl": "Goedgekeurd", "ru": "Подтверждено"},
"targets": ["kmp"]
},
"status_connecting": {
"context": "Targeted transfer status: connecting to the saved device.",
"translations": {"en": "Connecting", "fr": "Connexion", "es": "Conectando", "it": "Connessione", "de": "Verbindung wird hergestellt", "pt": "A ligar", "pl": "Łączenie", "nl": "Verbinden", "ru": "Подключение"},
"targets": ["kmp"]
},
"status_interrupted": {
"context": "Targeted transfer status: interrupted and resumable.",
"translations": {"en": "Interrupted", "fr": "Interrompu", "es": "Interrumpido", "it": "Interrotto", "de": "Unterbrochen", "pt": "Interrompido", "pl": "Przerwany", "nl": "Onderbroken", "ru": "Прервано"},
"targets": ["kmp"]
},
"status_transferring": {
"context": "Targeted transfer status: authenticated content transfer is active.",
"translations": {"en": "Transferring", "fr": "Transfert", "es": "Transfiriendo", "it": "Trasferimento", "de": "Übertragung läuft", "pt": "A transferir", "pl": "Przesyłanie", "nl": "Overdragen", "ru": "Передача"},
"targets": ["kmp"]
},
"status_declined": {
"context": "Targeted transfer status: declined by the saved device.",
"translations": {"en": "Declined", "fr": "Refusé", "es": "Rechazado", "it": "Rifiutato", "de": "Abgelehnt", "pt": "Recusado", "pl": "Odrzucony", "nl": "Geweigerd", "ru": "Отклонено"},
"targets": ["kmp"]
},
"saved_devices_authenticated_name": { "saved_devices_authenticated_name": {
"context": "Saved-device card: authenticated name shared by the remote app when a local label is also shown.", "context": "Saved-device card: authenticated name shared by the remote app when a local label is also shown.",
"translations": { "translations": {
@@ -4914,22 +5093,37 @@
] ]
}, },
"saved_devices_empty": { "saved_devices_empty": {
"context": "Saved devices screen: empty list when none are saved yet.", "context": "Saved devices screen: guidance shown when no device has been saved.",
"translations": { "translations": {
"en": "No saved devices yet. Finish a transfer, then remember a device.", "en": "Finish a transfer, then choose to remember the other device.",
"fr": "No saved devices yet. Finish a transfer, then remember a device.", "fr": "Terminez un transfert, puis choisissez de mémoriser lautre appareil.",
"es": "No saved devices yet. Finish a transfer, then remember a device.", "es": "Finaliza una transferencia y elige recordar el otro dispositivo.",
"it": "No saved devices yet. Finish a transfer, then remember a device.", "it": "Completa un trasferimento, quindi scegli di ricordare laltro dispositivo.",
"de": "No saved devices yet. Finish a transfer, then remember a device.", "de": "Schließe eine Übertragung ab und speichere anschließend das andere Gerät.",
"pt": "No saved devices yet. Finish a transfer, then remember a device.", "pt": "Conclua uma transferência e escolha guardar o outro dispositivo.",
"pl": "No saved devices yet. Finish a transfer, then remember a device.", "pl": "Ukończ transfer, a następnie zapisz drugie urządzenie.",
"nl": "No saved devices yet. Finish a transfer, then remember a device.", "nl": "Voltooi een overdracht en kies daarna om het andere apparaat te onthouden.",
"ru": "No saved devices yet. Finish a transfer, then remember a device." "ru": "Завершите передачу, затем сохраните другое устройство."
}, },
"targets": [ "targets": [
"kmp" "kmp"
] ]
}, },
"saved_devices_empty_title": {
"context": "Saved devices screen: title for the empty state.",
"translations": {
"en": "No saved devices",
"fr": "Aucun appareil enregistré",
"es": "No hay dispositivos guardados",
"it": "Nessun dispositivo salvato",
"de": "Keine gespeicherten Geräte",
"pt": "Nenhum dispositivo guardado",
"pl": "Brak zapisanych urządzeń",
"nl": "Geen opgeslagen apparaten",
"ru": "Нет сохранённых устройств"
},
"targets": ["kmp"]
},
"saved_devices_forget_confirm_body": { "saved_devices_forget_confirm_body": {
"context": "Confirmation message before forgetting a saved device. Placeholder is the display name.", "context": "Confirmation message before forgetting a saved device. Placeholder is the display name.",
"translations": { "translations": {

View File

@@ -24,6 +24,8 @@ lists, animation, accessibility:
3. Open **at most one** file under `.codex/skills/compose-skill/references/` when 3. Open **at most one** file under `.codex/skills/compose-skill/references/` when
the skill links to it for the current task. the skill links to it for the current task.
4. Do **not** invent a parallel Compose style guide. 4. Do **not** invent a parallel Compose style guide.
5. For visible UI changes, complete the skill's rendered-app visual QA gate.
Tests and compilation alone are not a finished UI handoff.
### Project policy ### Project policy

View File

@@ -319,12 +319,32 @@
<string name="offer_body">%1$s möchte Ihnen „%2$s“ senden.</string> <string name="offer_body">%1$s möchte Ihnen „%2$s“ senden.</string>
<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_devices_title">Deine Geräte</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_direction_incoming">Von %%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_progress">%%1$s von %%2$s</string>
<string name="saved_devices_transfer_receive">Empfangen</string>
<string name="saved_devices_transfer_resume">Fortsetzen</string>
<string name="saved_devices_transfer_cancel">Abbrechen</string>
<string name="saved_devices_transfer_delete">Löschen</string>
<string name="status_offering">Wird angeboten</string>
<string name="status_awaiting_approval">Wartet auf Genehmigung</string>
<string name="status_approved">Genehmigt</string>
<string name="status_connecting">Verbindung wird hergestellt</string>
<string name="status_interrupted">Unterbrochen</string>
<string name="status_transferring">Übertragung läuft</string>
<string name="status_declined">Abgelehnt</string>
<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_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">No saved devices yet. Finish a transfer, then remember a device.</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_forget_confirm_body">%1$s vergessen? Vor einer weiteren direkten Übertragung müssen beide das Speichern erneut bestätigen.</string> <string name="saved_devices_forget_confirm_body">%1$s vergessen? Vor einer weiteren direkten Übertragung müssen beide das Speichern erneut bestätigen.</string>
<string name="saved_devices_forget_confirm_title">Gerät vergessen?</string> <string name="saved_devices_forget_confirm_title">Gerät vergessen?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string> <string name="saved_devices_eligibility_title">Ready to remember</string>

View File

@@ -319,12 +319,32 @@
<string name="offer_body">%1$s quiere enviarte «%2$s».</string> <string name="offer_body">%1$s quiere enviarte «%2$s».</string>
<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_devices_title">Tus dispositivos</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_direction_incoming">De %%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_progress">%%1$s de %%2$s</string>
<string name="saved_devices_transfer_receive">Recibir</string>
<string name="saved_devices_transfer_resume">Reanudar</string>
<string name="saved_devices_transfer_cancel">Cancelar</string>
<string name="saved_devices_transfer_delete">Eliminar</string>
<string name="status_offering">Ofreciendo</string>
<string name="status_awaiting_approval">Esperando aprobación</string>
<string name="status_approved">Aprobado</string>
<string name="status_connecting">Conectando</string>
<string name="status_interrupted">Interrumpido</string>
<string name="status_transferring">Transfiriendo</string>
<string name="status_declined">Rechazado</string>
<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_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">No saved devices yet. Finish a transfer, then remember a device.</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_forget_confirm_body">¿Olvidar a %1$s? Ambos deberán volver a aprobar el guardado antes de otra transferencia directa.</string> <string name="saved_devices_forget_confirm_body">¿Olvidar a %1$s? Ambos deberán volver a aprobar el guardado antes de otra transferencia directa.</string>
<string name="saved_devices_forget_confirm_title">¿Olvidar dispositivo?</string> <string name="saved_devices_forget_confirm_title">¿Olvidar dispositivo?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string> <string name="saved_devices_eligibility_title">Ready to remember</string>

View File

@@ -319,12 +319,32 @@
<string name="offer_body">%1$s souhaite vous envoyer « %2$s ».</string> <string name="offer_body">%1$s souhaite vous envoyer « %2$s ».</string>
<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_devices_title">Vos appareils</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_direction_incoming">De %%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_progress">%%1$s sur %%2$s</string>
<string name="saved_devices_transfer_receive">Recevoir</string>
<string name="saved_devices_transfer_resume">Reprendre</string>
<string name="saved_devices_transfer_cancel">Annuler</string>
<string name="saved_devices_transfer_delete">Supprimer</string>
<string name="status_offering">Proposition</string>
<string name="status_awaiting_approval">En attente dapprobation</string>
<string name="status_approved">Approuvé</string>
<string name="status_connecting">Connexion</string>
<string name="status_interrupted">Interrompu</string>
<string name="status_transferring">Transfert</string>
<string name="status_declined">Refusé</string>
<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_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">No saved devices yet. Finish a transfer, then remember a device.</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_forget_confirm_body">Oublier %1$s ? Vous devrez tous les deux approuver à nouveau lenregistrement avant un autre transfert direct.</string> <string name="saved_devices_forget_confirm_body">Oublier %1$s ? Vous devrez tous les deux approuver à nouveau lenregistrement avant un autre transfert direct.</string>
<string name="saved_devices_forget_confirm_title">Oublier lappareil ?</string> <string name="saved_devices_forget_confirm_title">Oublier lappareil ?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string> <string name="saved_devices_eligibility_title">Ready to remember</string>

View File

@@ -319,12 +319,32 @@
<string name="offer_body">%1$s vuole inviarti «%2$s».</string> <string name="offer_body">%1$s vuole inviarti «%2$s».</string>
<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_devices_title">I tuoi dispositivi</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_direction_incoming">Da %%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_progress">%%1$s di %%2$s</string>
<string name="saved_devices_transfer_receive">Ricevi</string>
<string name="saved_devices_transfer_resume">Riprendi</string>
<string name="saved_devices_transfer_cancel">Annulla</string>
<string name="saved_devices_transfer_delete">Elimina</string>
<string name="status_offering">Proposta</string>
<string name="status_awaiting_approval">In attesa di approvazione</string>
<string name="status_approved">Approvato</string>
<string name="status_connecting">Connessione</string>
<string name="status_interrupted">Interrotto</string>
<string name="status_transferring">Trasferimento</string>
<string name="status_declined">Rifiutato</string>
<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_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">No saved devices yet. Finish a transfer, then remember a device.</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_forget_confirm_body">Dimenticare %1$s? Entrambi dovrete approvare nuovamente il salvataggio prima di un altro trasferimento diretto.</string> <string name="saved_devices_forget_confirm_body">Dimenticare %1$s? Entrambi dovrete approvare nuovamente il salvataggio prima di un altro trasferimento diretto.</string>
<string name="saved_devices_forget_confirm_title">Dimenticare il dispositivo?</string> <string name="saved_devices_forget_confirm_title">Dimenticare il dispositivo?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string> <string name="saved_devices_eligibility_title">Ready to remember</string>

View File

@@ -319,12 +319,32 @@
<string name="offer_body">%1$s wil je “%2$s” sturen.</string> <string name="offer_body">%1$s wil je “%2$s” sturen.</string>
<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_devices_title">Je apparaten</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_direction_incoming">Van %%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_progress">%%1$s van %%2$s</string>
<string name="saved_devices_transfer_receive">Ontvangen</string>
<string name="saved_devices_transfer_resume">Hervatten</string>
<string name="saved_devices_transfer_cancel">Annuleren</string>
<string name="saved_devices_transfer_delete">Verwijderen</string>
<string name="status_offering">Aanbieden</string>
<string name="status_awaiting_approval">Wachten op goedkeuring</string>
<string name="status_approved">Goedgekeurd</string>
<string name="status_connecting">Verbinden</string>
<string name="status_interrupted">Onderbroken</string>
<string name="status_transferring">Overdragen</string>
<string name="status_declined">Geweigerd</string>
<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_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">No saved devices yet. Finish a transfer, then remember a device.</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_forget_confirm_body">%1$s vergeten? Jullie moeten het opslaan allebei opnieuw goedkeuren voor een volgende directe overdracht.</string> <string name="saved_devices_forget_confirm_body">%1$s vergeten? Jullie moeten het opslaan allebei opnieuw goedkeuren voor een volgende directe overdracht.</string>
<string name="saved_devices_forget_confirm_title">Apparaat vergeten?</string> <string name="saved_devices_forget_confirm_title">Apparaat vergeten?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string> <string name="saved_devices_eligibility_title">Ready to remember</string>

View File

@@ -319,12 +319,32 @@
<string name="offer_body">%1$s chce wysłać Ci „%2$s”.</string> <string name="offer_body">%1$s chce wysłać Ci „%2$s”.</string>
<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_devices_title">Twoje urządzenia</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_direction_incoming">Od %%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_progress">%%1$s z %%2$s</string>
<string name="saved_devices_transfer_receive">Odbierz</string>
<string name="saved_devices_transfer_resume">Wznów</string>
<string name="saved_devices_transfer_cancel">Anuluj</string>
<string name="saved_devices_transfer_delete">Usuń</string>
<string name="status_offering">Oferowanie</string>
<string name="status_awaiting_approval">Oczekiwanie na zgodę</string>
<string name="status_approved">Zatwierdzony</string>
<string name="status_connecting">Łączenie</string>
<string name="status_interrupted">Przerwany</string>
<string name="status_transferring">Przesyłanie</string>
<string name="status_declined">Odrzucony</string>
<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_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">No saved devices yet. Finish a transfer, then remember a device.</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_forget_confirm_body">Zapomnieć %1$s? Przed kolejnym transferem bezpośrednim obie strony muszą ponownie zatwierdzić zapisanie.</string> <string name="saved_devices_forget_confirm_body">Zapomnieć %1$s? Przed kolejnym transferem bezpośrednim obie strony muszą ponownie zatwierdzić zapisanie.</string>
<string name="saved_devices_forget_confirm_title">Zapomnieć urządzenie?</string> <string name="saved_devices_forget_confirm_title">Zapomnieć urządzenie?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string> <string name="saved_devices_eligibility_title">Ready to remember</string>

View File

@@ -319,12 +319,32 @@
<string name="offer_body">%1$s quer enviar-lhe “%2$s”.</string> <string name="offer_body">%1$s quer enviar-lhe “%2$s”.</string>
<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_devices_title">Os seus dispositivos</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_direction_incoming">De %%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_progress">%%1$s de %%2$s</string>
<string name="saved_devices_transfer_receive">Receber</string>
<string name="saved_devices_transfer_resume">Retomar</string>
<string name="saved_devices_transfer_cancel">Cancelar</string>
<string name="saved_devices_transfer_delete">Eliminar</string>
<string name="status_offering">A propor</string>
<string name="status_awaiting_approval">A aguardar aprovação</string>
<string name="status_approved">Aprovado</string>
<string name="status_connecting">A ligar</string>
<string name="status_interrupted">Interrompido</string>
<string name="status_transferring">A transferir</string>
<string name="status_declined">Recusado</string>
<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_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">No saved devices yet. Finish a transfer, then remember a device.</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_forget_confirm_body">Esquecer %1$s? Ambos terão de voltar a aprovar antes de outra transferência direta.</string> <string name="saved_devices_forget_confirm_body">Esquecer %1$s? Ambos terão de voltar a aprovar antes de outra transferência direta.</string>
<string name="saved_devices_forget_confirm_title">Esquecer dispositivo?</string> <string name="saved_devices_forget_confirm_title">Esquecer dispositivo?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string> <string name="saved_devices_eligibility_title">Ready to remember</string>

View File

@@ -319,12 +319,32 @@
<string name="offer_body">%1$s хочет отправить вам «%2$s».</string> <string name="offer_body">%1$s хочет отправить вам «%2$s».</string>
<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_devices_title">Ваши устройства</string>
<string name="saved_devices_transfers_title">Прямые передачи</string>
<string name="saved_devices_transfer_empty">Прямых передач пока нет.</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_files">%%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_resume">Продолжить</string>
<string name="saved_devices_transfer_cancel">Отменить</string>
<string name="saved_devices_transfer_delete">Удалить</string>
<string name="status_offering">Предложение</string>
<string name="status_awaiting_approval">Ожидание подтверждения</string>
<string name="status_approved">Подтверждено</string>
<string name="status_connecting">Подключение</string>
<string name="status_interrupted">Прервано</string>
<string name="status_transferring">Передача</string>
<string name="status_declined">Отклонено</string>
<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_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">No saved devices yet. Finish a transfer, then remember a device.</string> <string name="saved_devices_empty">Завершите передачу, затем сохраните другое устройство.</string>
<string name="saved_devices_empty_title">Нет сохранённых устройств</string>
<string name="saved_devices_forget_confirm_body">Забыть %1$s? Перед следующей прямой передачей сохранение снова должны подтвердить обе стороны.</string> <string name="saved_devices_forget_confirm_body">Забыть %1$s? Перед следующей прямой передачей сохранение снова должны подтвердить обе стороны.</string>
<string name="saved_devices_forget_confirm_title">Забыть устройство?</string> <string name="saved_devices_forget_confirm_title">Забыть устройство?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string> <string name="saved_devices_eligibility_title">Ready to remember</string>

View File

@@ -319,12 +319,32 @@
<string name="offer_body">%1$s wants to send you “%2$s”.</string> <string name="offer_body">%1$s wants to send you “%2$s”.</string>
<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_devices_title">Your devices</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_direction_incoming">From %%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_progress">%%1$s of %%2$s</string>
<string name="saved_devices_transfer_receive">Receive</string>
<string name="saved_devices_transfer_resume">Resume</string>
<string name="saved_devices_transfer_cancel">Cancel</string>
<string name="saved_devices_transfer_delete">Delete</string>
<string name="status_offering">Offering</string>
<string name="status_awaiting_approval">Waiting for approval</string>
<string name="status_approved">Approved</string>
<string name="status_connecting">Connecting</string>
<string name="status_interrupted">Interrupted</string>
<string name="status_transferring">Transferring</string>
<string name="status_declined">Declined</string>
<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_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">No saved devices yet. Finish a transfer, then remember a 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_forget_confirm_body">Forget %1$s? You will both need to approve saving again before another direct transfer.</string> <string name="saved_devices_forget_confirm_body">Forget %1$s? You will both need to approve saving again before another direct transfer.</string>
<string name="saved_devices_forget_confirm_title">Forget device?</string> <string name="saved_devices_forget_confirm_title">Forget device?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string> <string name="saved_devices_eligibility_title">Ready to remember</string>

View File

@@ -132,6 +132,8 @@ fun App(
val savedDevicesViewModel = viewModel { val savedDevicesViewModel = viewModel {
SavedDevicesViewModel( SavedDevicesViewModel(
graph.coreRepository, graph.coreRepository,
dependencies.fileSystemService,
graph.preferencesRepository,
graph.messages, graph.messages,
) )
} }
@@ -141,8 +143,7 @@ fun App(
val receiveState by receiveViewModel.state.collectAsStateWithLifecycle() val receiveState by receiveViewModel.state.collectAsStateWithLifecycle()
val receiveCoreState by receiveViewModel.coreState.collectAsStateWithLifecycle() val receiveCoreState by receiveViewModel.coreState.collectAsStateWithLifecycle()
val approvalState by graph.approvalCoordinator.state.collectAsStateWithLifecycle() val approvalState by graph.approvalCoordinator.state.collectAsStateWithLifecycle()
val pairingPromptState by graph.pairingPromptCoordinator.state.collectAsStateWithLifecycle() val savedDevicesState by savedDevicesViewModel.state.collectAsStateWithLifecycle()
val targetedOfferState by graph.targetedOfferCoordinator.state.collectAsStateWithLifecycle()
val username by graph.preferencesRepository.preferences val username by graph.preferencesRepository.preferences
.map { it.username } .map { it.username }
.collectAsStateWithLifecycle(initialValue = dependencies.environment.defaultUsername) .collectAsStateWithLifecycle(initialValue = dependencies.environment.defaultUsername)
@@ -266,17 +267,19 @@ fun App(
onAccept = graph.approvalCoordinator::accept, onAccept = graph.approvalCoordinator::accept,
onRefuse = graph.approvalCoordinator::refuse, onRefuse = graph.approvalCoordinator::refuse,
) )
if (appState.destination != AppDestination.SavedDevices) {
PairingPromptHost( PairingPromptHost(
state = pairingPromptState, state = savedDevicesState.pairingPrompt,
onAccept = graph.pairingPromptCoordinator::accept, onAccept = savedDevicesViewModel::acceptPairingPrompt,
onDecline = graph.pairingPromptCoordinator::decline, onDecline = savedDevicesViewModel::declinePairingPrompt,
onDismiss = graph.pairingPromptCoordinator::dismiss, onDismiss = savedDevicesViewModel::dismissPairingPrompt,
) )
TargetedOfferModalHost( TargetedOfferModalHost(
state = targetedOfferState, state = savedDevicesState.targetedOffers,
onAccept = graph.targetedOfferCoordinator::accept, onAccept = savedDevicesViewModel::acceptTargetedOffer,
onDecline = graph.targetedOfferCoordinator::decline, onDecline = savedDevicesViewModel::declineTargetedOffer,
) )
}
TransferDraftHost(targetedDraftViewModel, windowClass, onCreated = {}) TransferDraftHost(targetedDraftViewModel, windowClass, onCreated = {})
} }
windowChrome?.invoke() windowChrome?.invoke()

View File

@@ -5,8 +5,6 @@ import com.vnidrop.app.core.CoreRepository
import com.vnidrop.app.diagnostics.DiagnosticsCoordinator import com.vnidrop.app.diagnostics.DiagnosticsCoordinator
import com.vnidrop.app.diagnostics.createDiagnosticsTransport import com.vnidrop.app.diagnostics.createDiagnosticsTransport
import com.vnidrop.app.feature.approvals.ApprovalCoordinator import com.vnidrop.app.feature.approvals.ApprovalCoordinator
import com.vnidrop.app.feature.saveddevices.PairingPromptCoordinator
import com.vnidrop.app.feature.saveddevices.TargetedOfferCoordinator
import com.vnidrop.app.feature.send.AppFilePreviewRepository import com.vnidrop.app.feature.send.AppFilePreviewRepository
import com.vnidrop.app.feature.send.createPlatformPreviewStore import com.vnidrop.app.feature.send.createPlatformPreviewStore
import com.vnidrop.app.logging.AppLogger import com.vnidrop.app.logging.AppLogger
@@ -60,18 +58,6 @@ class AppGraph(
messages = messages, messages = messages,
scope = applicationScope, scope = applicationScope,
) )
val pairingPromptCoordinator = PairingPromptCoordinator(
repository = coreRepository,
messages = messages,
scope = applicationScope,
)
val targetedOfferCoordinator = TargetedOfferCoordinator(
repository = coreRepository,
fileSystemService = dependencies.fileSystemService,
preferencesRepository = preferencesRepository,
messages = messages,
scope = applicationScope,
)
val transferNotificationCoordinator = TransferNotificationCoordinator( val transferNotificationCoordinator = TransferNotificationCoordinator(
repository = coreRepository, repository = coreRepository,
preferencesRepository = preferencesRepository, preferencesRepository = preferencesRepository,

View File

@@ -228,6 +228,7 @@ interface CoreGateway {
outputSink: ReceiveOutputSinkV2, outputSink: ReceiveOutputSinkV2,
): Result<Unit> ): Result<Unit>
suspend fun resumeTargetedTransfer(id: String, outputDir: String): Result<Unit> suspend fun resumeTargetedTransfer(id: String, outputDir: String): Result<Unit>
suspend fun resumeTargetedTransferWithOutputSinkV2(id: String, outputSink: ReceiveOutputSinkV2): Result<Unit>
suspend fun cancelTargetedTransfer(id: String): Result<Unit> suspend fun cancelTargetedTransfer(id: String): Result<Unit>
suspend fun deleteTargetedTransfer(id: String): Result<Unit> suspend fun deleteTargetedTransfer(id: String): Result<Unit>
} }

View File

@@ -358,6 +358,13 @@ class CoreRepository internal constructor(
activeCore.resumeTargetedTransfer(id, outputDir) activeCore.resumeTargetedTransfer(id, outputDir)
} }
override suspend fun resumeTargetedTransferWithOutputSinkV2(
id: String,
outputSink: ReceiveOutputSinkV2,
): Result<Unit> = runCore { activeCore ->
activeCore.resumeTargetedTransferWithOutputSinkV2(id, outputSink)
}
override suspend fun cancelTargetedTransfer(id: String): Result<Unit> = runCore { activeCore -> override suspend fun cancelTargetedTransfer(id: String): Result<Unit> = runCore { activeCore ->
activeCore.cancelTargetedTransfer(id) activeCore.cancelTargetedTransfer(id)
} }

View File

@@ -1,150 +0,0 @@
package com.vnidrop.app.feature.saveddevices
import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.CoreSignal
import com.vnidrop.app.core.DeviceRelationshipStateModel
import com.vnidrop.app.ui.feedback.UiMessageController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
sealed interface PairingPrompt {
/** Local eligibility after a completed invitation transfer — user may remember the peer. */
data class Eligibility(val peerEndpointId: String, val remoteDisplayName: String?) : PairingPrompt
/** Peer requested pairing; user may accept or decline mutual consent. */
data class IncomingRequest(val peerEndpointId: String, val remoteDisplayName: String?) : PairingPrompt
}
data class PairingPromptState(
val prompt: PairingPrompt? = null,
val busy: Boolean = false,
)
/**
* Foreground in-flow pairing prompts. Dismiss keeps durable eligibility /
* pending relationship visible in the Saved devices area; Decline consumes it.
*/
class PairingPromptCoordinator(
private val repository: CoreGateway,
private val messages: UiMessageController,
private val scope: CoroutineScope,
) {
private val _state = MutableStateFlow(PairingPromptState())
val state: StateFlow<PairingPromptState> = _state.asStateFlow()
/** Peers whose in-flow eligibility prompt was dismissed this session. */
private val dismissedEligibility = mutableSetOf<String>()
init {
scope.launch {
repository.state.map { it.isInitialized }
.distinctUntilChanged()
.collect { initialized ->
if (initialized) refresh()
}
}
scope.launch {
repository.signals.collect { signal ->
when (signal) {
CoreSignal.PairingChanged -> {
if (repository.state.value.isInitialized) refresh()
}
is CoreSignal.ApprovalChanged,
is CoreSignal.ReceiverHistoryChanged,
is CoreSignal.TransfersChanged,
CoreSignal.TargetedTransferChanged -> Unit
}
}
}
}
fun accept() {
val prompt = _state.value.prompt ?: return
if (_state.value.busy) return
_state.update { it.copy(busy = true) }
scope.launch {
val result = when (prompt) {
is PairingPrompt.Eligibility -> repository.requestSavedDevicePairing(prompt.peerEndpointId)
is PairingPrompt.IncomingRequest ->
repository.respondToDevicePairing(prompt.peerEndpointId, accepted = true)
}
_state.update { it.copy(busy = false) }
result.fold(
onSuccess = { refresh() },
onFailure = messages::error,
)
}
}
fun decline() {
val prompt = _state.value.prompt ?: return
if (_state.value.busy) return
_state.update { it.copy(busy = true) }
scope.launch {
val result = when (prompt) {
is PairingPrompt.Eligibility -> repository.declinePairingEligibility(prompt.peerEndpointId)
is PairingPrompt.IncomingRequest ->
repository.respondToDevicePairing(prompt.peerEndpointId, accepted = false)
}
_state.update { it.copy(busy = false) }
result.fold(
onSuccess = { refresh() },
onFailure = messages::error,
)
}
}
/** Close the dialog without consuming durable eligibility / pending state. */
fun dismiss() {
val prompt = _state.value.prompt ?: return
if (prompt is PairingPrompt.Eligibility) {
dismissedEligibility += prompt.peerEndpointId
}
_state.update { it.copy(prompt = null) }
}
private suspend fun refresh() {
if (_state.value.busy) return
val relationships = repository.listDeviceRelationships().getOrElse {
messages.error(it)
return
}
val incoming = relationships.firstOrNull { it.state == DeviceRelationshipStateModel.PendingIncoming }
if (incoming != null) {
val remoteDisplayName = repository.listPairingEligibilities()
.getOrElse {
messages.error(it)
emptyList()
}
.firstOrNull { eligibility -> eligibility.peerEndpointId == incoming.remoteEndpointId }
?.remoteDisplayName
_state.update {
it.copy(
prompt = PairingPrompt.IncomingRequest(
incoming.remoteEndpointId,
remoteDisplayName,
),
)
}
return
}
val eligibilities = repository.listPairingEligibilities().getOrElse {
messages.error(it)
return
}
val eligibility = eligibilities.firstOrNull { it.peerEndpointId !in dismissedEligibility }
_state.update {
it.copy(
prompt = eligibility?.let { row ->
PairingPrompt.Eligibility(row.peerEndpointId, row.remoteDisplayName)
},
)
}
}
}

View File

@@ -0,0 +1,46 @@
package com.vnidrop.app.feature.saveddevices
import com.vnidrop.app.core.PendingTargetedOfferModel
import com.vnidrop.app.core.TargetedTransferStateModel
sealed interface PairingPrompt {
data class Eligibility(val peerEndpointId: String, val remoteDisplayName: String?) : PairingPrompt
data class IncomingRequest(val peerEndpointId: String, val remoteDisplayName: String?) : PairingPrompt
}
data class PairingPromptState(
val prompt: PairingPrompt? = null,
val busy: Boolean = false,
)
data class TargetedOfferState(
val pending: List<PendingTargetedOfferModel> = emptyList(),
val senderDisplayNames: Map<String, String> = emptyMap(),
val respondingIds: Set<String> = emptySet(),
) {
val current: PendingTargetedOfferModel?
get() = pending.firstOrNull()
val currentSenderDisplayName: String?
get() = current?.senderEndpointId?.let(senderDisplayNames::get)
}
enum class SavedDeviceTransferDirection {
Outgoing,
Incoming,
}
data class SavedDeviceTransferItem(
val id: String,
val peerEndpointId: String,
val peerDisplayName: String?,
val direction: SavedDeviceTransferDirection,
val transferName: String,
val fileCount: ULong,
val totalSize: ULong,
val verifiedBytes: ULong,
val state: TargetedTransferStateModel,
val createdAt: Long,
val updatedAt: Long,
)

View File

@@ -0,0 +1,544 @@
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.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
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.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.DeviceRelationshipModel
import com.vnidrop.app.core.DeviceRelationshipStateModel
import com.vnidrop.app.core.PairingEligibilityModel
import com.vnidrop.app.core.PendingTargetedOfferModel
import com.vnidrop.app.core.SavedDeviceModel
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.SecondaryButton
import com.vnidrop.app.ui.icons.AppIcon
import com.vnidrop.app.ui.icons.PlatformIcon
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_cancel
import vnidrop.shared.generated.resources.button_retry
import vnidrop.shared.generated.resources.offer_accept
import vnidrop.shared.generated.resources.offer_body
import vnidrop.shared.generated.resources.offer_decline
import vnidrop.shared.generated.resources.offer_title
import vnidrop.shared.generated.resources.saved_devices_accept_pairing_action
import vnidrop.shared.generated.resources.saved_devices_attention_title
import vnidrop.shared.generated.resources.saved_devices_authenticated_name
import vnidrop.shared.generated.resources.saved_devices_block_action
import vnidrop.shared.generated.resources.saved_devices_block_confirm_body
import vnidrop.shared.generated.resources.saved_devices_block_confirm_title
import vnidrop.shared.generated.resources.saved_devices_decline_action
import vnidrop.shared.generated.resources.saved_devices_devices_title
import vnidrop.shared.generated.resources.saved_devices_eligibility_title
import vnidrop.shared.generated.resources.saved_devices_empty
import vnidrop.shared.generated.resources.saved_devices_empty_title
import vnidrop.shared.generated.resources.saved_devices_endpoint
import vnidrop.shared.generated.resources.saved_devices_forget_action
import vnidrop.shared.generated.resources.saved_devices_forget_confirm_body
import vnidrop.shared.generated.resources.saved_devices_forget_confirm_title
import vnidrop.shared.generated.resources.saved_devices_label_action
import vnidrop.shared.generated.resources.saved_devices_load_failed
import vnidrop.shared.generated.resources.saved_devices_more_actions
import vnidrop.shared.generated.resources.saved_devices_pending_incoming
import vnidrop.shared.generated.resources.saved_devices_pending_outgoing
import vnidrop.shared.generated.resources.saved_devices_remember_action
import vnidrop.shared.generated.resources.saved_devices_send_action
import vnidrop.shared.generated.resources.saved_devices_transfer_empty
import vnidrop.shared.generated.resources.saved_devices_transfers_title
import vnidrop.shared.generated.resources.saved_devices_unnamed
@Composable
internal fun CompactSavedDevicesHub(
state: SavedDevicesState,
onRetry: () -> Unit,
onRememberEligible: (String) -> Unit,
onDeclineEligible: (String) -> Unit,
onAcceptIncoming: (String) -> Unit,
onDeclineIncoming: (String) -> Unit,
onAcceptOffer: (String) -> Unit,
onDeclineOffer: (String) -> Unit,
onSend: (String) -> Unit,
onOpenLabel: (String) -> Unit,
onForget: (String) -> Unit,
onBlock: (String) -> Unit,
onTransferAction: (String, SavedDeviceTransferAction) -> Unit,
) {
val hasAttention = state.attentionCount > 0
val isEmpty = !hasAttention && state.savedDevices.isEmpty() && state.targetedTransfers.isEmpty()
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 28.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
if (state.isLoading) item(key = "loading") { androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth()) }
if (state.loadFailed) item(key = "load-failed") { InlineLoadFailure(onRetry) }
if (hasAttention) {
item(key = "attention") {
AttentionSection(
state,
onRememberEligible,
onDeclineEligible,
onAcceptIncoming,
onDeclineIncoming,
onAcceptOffer,
onDeclineOffer,
)
}
}
if (state.savedDevices.isNotEmpty()) {
item(key = "saved-devices") {
SavedDeviceSection(state, onSend, onOpenLabel, onForget, onBlock)
}
}
if (state.targetedTransfers.isNotEmpty()) {
targetedTransferItems(state.targetedTransfers, state.busyTransferIds, onTransferAction)
}
if (isEmpty) item(key = "empty") { SavedDevicesEmptyState(Modifier.fillParentMaxHeight(0.72f)) }
}
}
@Composable
internal fun DesktopSavedDevicesHub(
state: SavedDevicesState,
onRetry: () -> Unit,
onRememberEligible: (String) -> Unit,
onDeclineEligible: (String) -> Unit,
onAcceptIncoming: (String) -> Unit,
onDeclineIncoming: (String) -> Unit,
onAcceptOffer: (String) -> Unit,
onDeclineOffer: (String) -> Unit,
onSend: (String) -> Unit,
onOpenLabel: (String) -> Unit,
onForget: (String) -> Unit,
onBlock: (String) -> Unit,
onTransferAction: (String, SavedDeviceTransferAction) -> Unit,
) {
val hasAttention = state.attentionCount > 0
val isEmpty = !hasAttention && state.savedDevices.isEmpty() && state.targetedTransfers.isEmpty()
if (isEmpty && !state.isLoading && !state.loadFailed) {
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
SavedDevicesEmptyState(Modifier.widthIn(max = 440.dp))
}
return
}
Row(
modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp),
horizontalArrangement = Arrangement.spacedBy(32.dp),
) {
LazyColumn(
modifier = Modifier.weight(0.9f).fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(20.dp),
contentPadding = PaddingValues(bottom = 24.dp),
) {
if (state.isLoading) item(key = "loading") { androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth()) }
if (state.loadFailed) item(key = "load-failed") { InlineLoadFailure(onRetry) }
if (hasAttention) {
item(key = "attention") {
AttentionSection(
state,
onRememberEligible,
onDeclineEligible,
onAcceptIncoming,
onDeclineIncoming,
onAcceptOffer,
onDeclineOffer,
)
}
}
if (state.savedDevices.isNotEmpty()) {
item(key = "saved-devices") {
SavedDeviceSection(state, onSend, onOpenLabel, onForget, onBlock)
}
}
}
LazyColumn(
modifier = Modifier.weight(1.1f).fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(bottom = 24.dp),
) {
if (state.targetedTransfers.isNotEmpty()) {
targetedTransferItems(
state.targetedTransfers,
state.busyTransferIds,
onTransferAction,
presentation = TargetedTransferPresentation.DesktopRow,
)
} else {
item(key = "targeted-transfer-title") { SectionTitle(stringResource(Res.string.saved_devices_transfers_title)) }
item(key = "targeted-transfer-empty") {
Text(
stringResource(Res.string.saved_devices_transfer_empty),
style = MaterialTheme.typography.bodyMedium,
color = LocalVniDropColors.current.foregroundLight,
)
}
}
}
}
}
private val SavedDevicesState.attentionCount: Int
get() = targetedOffers.pending.size + eligibilities.size + pendingRelationships.size
@Composable
private fun AttentionSection(
state: SavedDevicesState,
onRememberEligible: (String) -> Unit,
onDeclineEligible: (String) -> Unit,
onAcceptIncoming: (String) -> Unit,
onDeclineIncoming: (String) -> Unit,
onAcceptOffer: (String) -> Unit,
onDeclineOffer: (String) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
SectionTitle(stringResource(Res.string.saved_devices_attention_title))
Surface(
shape = RoundedCornerShape(12.dp),
color = LocalVniDropColors.current.backgroundSurface200,
) {
Column {
var hasPrevious = false
state.targetedOffers.pending.forEach { offer ->
if (hasPrevious) GroupDivider()
TargetedOfferRow(
offer,
state.targetedOffers.senderDisplayNames[offer.senderEndpointId],
offer.transferId in state.targetedOffers.respondingIds,
{ onAcceptOffer(offer.transferId) },
{ onDeclineOffer(offer.transferId) },
)
hasPrevious = true
}
state.eligibilities.forEach { eligibility ->
if (hasPrevious) GroupDivider()
EligibilityRow(
eligibility,
eligibility.peerEndpointId in state.busyPeerIds,
{ onRememberEligible(eligibility.peerEndpointId) },
{ onDeclineEligible(eligibility.peerEndpointId) },
)
hasPrevious = true
}
state.pendingRelationships.forEach { relationship ->
if (hasPrevious) GroupDivider()
PendingPairingRow(
relationship,
state.eligibilities.firstOrNull { it.peerEndpointId == relationship.remoteEndpointId }?.remoteDisplayName,
relationship.remoteEndpointId in state.busyPeerIds,
{ onAcceptIncoming(relationship.remoteEndpointId) },
{ onDeclineIncoming(relationship.remoteEndpointId) },
)
hasPrevious = true
}
}
}
}
}
@Composable
private fun TargetedOfferRow(
offer: PendingTargetedOfferModel,
senderName: String?,
busy: Boolean,
onAccept: () -> Unit,
onDecline: () -> Unit,
) {
val deviceName = senderName?.takeIf(String::isNotBlank) ?: stringResource(Res.string.saved_devices_unnamed)
DecisionRow(
icon = AppIcon.Download,
title = stringResource(Res.string.offer_title),
body = stringResource(Res.string.offer_body, deviceName, offer.transferName),
endpointId = offer.senderEndpointId,
busy = busy,
) {
PrimaryButton(stringResource(Res.string.offer_accept), onAccept, enabled = !busy)
SecondaryButton(stringResource(Res.string.offer_decline), onDecline, enabled = !busy)
}
}
@Composable
private fun EligibilityRow(
eligibility: PairingEligibilityModel,
busy: Boolean,
onRemember: () -> Unit,
onDecline: () -> Unit,
) {
DecisionRow(
icon = AppIcon.Shield,
title = eligibility.remoteDisplayName?.takeIf(String::isNotBlank) ?: stringResource(Res.string.saved_devices_unnamed),
body = stringResource(Res.string.saved_devices_eligibility_title),
endpointId = eligibility.peerEndpointId,
busy = busy,
) {
PrimaryButton(stringResource(Res.string.saved_devices_remember_action), onRemember, enabled = !busy)
SecondaryButton(stringResource(Res.string.saved_devices_decline_action), onDecline, enabled = !busy)
}
}
@Composable
private fun PendingPairingRow(
relationship: DeviceRelationshipModel,
remoteDisplayName: String?,
busy: Boolean,
onAccept: () -> Unit,
onDecline: () -> Unit,
) {
DecisionRow(
icon = AppIcon.Shield,
title = remoteDisplayName?.takeIf(String::isNotBlank) ?: stringResource(Res.string.saved_devices_unnamed),
body = stringResource(
if (relationship.state == DeviceRelationshipStateModel.PendingIncoming) {
Res.string.saved_devices_pending_incoming
} else {
Res.string.saved_devices_pending_outgoing
},
),
endpointId = relationship.remoteEndpointId,
busy = busy,
actions = if (relationship.state == DeviceRelationshipStateModel.PendingIncoming) {
{
PrimaryButton(stringResource(Res.string.saved_devices_accept_pairing_action), onAccept, enabled = !busy)
SecondaryButton(stringResource(Res.string.saved_devices_decline_action), onDecline, enabled = !busy)
}
} else {
null
},
)
}
@Composable
private fun DecisionRow(
icon: AppIcon,
title: String,
body: String,
endpointId: String,
busy: Boolean,
actions: (@Composable RowScope.() -> Unit)?,
) {
val colors = LocalVniDropColors.current
Column(Modifier.padding(horizontal = 16.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
PlatformIcon(icon, contentDescription = null, tint = colors.brandLink, modifier = Modifier.padding(top = 2.dp).size(22.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Text(body, style = MaterialTheme.typography.bodyMedium, color = colors.foregroundLight)
DiagnosticEndpoint(endpointId)
}
if (busy) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
}
if (actions != null) Row(horizontalArrangement = Arrangement.spacedBy(8.dp), content = actions)
}
}
@Composable
private fun SavedDeviceSection(
state: SavedDevicesState,
onSend: (String) -> Unit,
onOpenLabel: (String) -> Unit,
onForget: (String) -> Unit,
onBlock: (String) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
SectionTitle(stringResource(Res.string.saved_devices_devices_title))
Surface(shape = RoundedCornerShape(12.dp), color = LocalVniDropColors.current.backgroundSurface200) {
Column {
state.savedDevices.forEachIndexed { index, device ->
if (index > 0) GroupDivider()
SavedDeviceRow(
device = device,
busy = device.endpointId in state.busyPeerIds,
onSend = { onSend(device.endpointId) },
onLabel = { onOpenLabel(device.endpointId) },
onForget = { onForget(device.endpointId) },
onBlock = { onBlock(device.endpointId) },
)
}
}
}
}
}
private enum class DeviceDestructiveAction { Forget, Block }
@Composable
private fun SavedDeviceRow(
device: SavedDeviceModel,
busy: Boolean,
onSend: () -> Unit,
onLabel: () -> Unit,
onForget: () -> Unit,
onBlock: () -> Unit,
) {
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(
modifier = Modifier.fillMaxWidth().testTag("saved-device-${device.endpointId}").padding(start = 16.dp, end = 6.dp, top = 12.dp, bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
PlatformIcon(AppIcon.ShieldCheck, contentDescription = null, tint = colors.foregroundLight, modifier = Modifier.size(24.dp))
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
device.remoteDisplayName
?.takeIf { device.localLabel?.isNotBlank() == true && it.isNotBlank() }
?.let { Text(stringResource(Res.string.saved_devices_authenticated_name, it), style = MaterialTheme.typography.bodySmall, color = colors.foregroundLight) }
DiagnosticEndpoint(device.endpointId)
}
if (busy) {
CircularProgressIndicator(Modifier.padding(horizontal = 14.dp).size(20.dp), strokeWidth = 2.dp)
} else {
val sendLabel = stringResource(Res.string.saved_devices_send_action)
IconButton(onClick = onSend) {
PlatformIcon(AppIcon.Send, contentDescription = sendLabel)
}
Box {
val moreLabel = stringResource(Res.string.saved_devices_more_actions, title)
IconButton(onClick = { menuExpanded = true }) {
PlatformIcon(AppIcon.MoreVertical, contentDescription = moreLabel)
}
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
DropdownMenuItem(
text = { Text(stringResource(Res.string.saved_devices_label_action)) },
onClick = { menuExpanded = false; onLabel() },
leadingIcon = { PlatformIcon(AppIcon.User, contentDescription = null) },
)
DropdownMenuItem(
text = { Text(stringResource(Res.string.saved_devices_forget_action)) },
onClick = { menuExpanded = false; pendingAction = DeviceDestructiveAction.Forget },
leadingIcon = { PlatformIcon(AppIcon.UserOff, contentDescription = null) },
)
DropdownMenuItem(
text = { Text(stringResource(Res.string.saved_devices_block_action)) },
onClick = { menuExpanded = false; pendingAction = DeviceDestructiveAction.Block },
leadingIcon = { PlatformIcon(AppIcon.Lock, contentDescription = null) },
)
}
}
}
}
pendingAction?.let { action ->
val isBlock = action == DeviceDestructiveAction.Block
AlertDialog(
onDismissRequest = { pendingAction = null },
title = { Text(stringResource(if (isBlock) Res.string.saved_devices_block_confirm_title else Res.string.saved_devices_forget_confirm_title)) },
text = {
Text(stringResource(if (isBlock) Res.string.saved_devices_block_confirm_body else Res.string.saved_devices_forget_confirm_body, title))
},
confirmButton = {
TextButton(onClick = { pendingAction = null; if (isBlock) onBlock() else onForget() }) {
Text(stringResource(if (isBlock) Res.string.saved_devices_block_action else Res.string.saved_devices_forget_action))
}
},
dismissButton = { TextButton(onClick = { pendingAction = null }) { Text(stringResource(Res.string.button_cancel)) } },
)
}
}
@Composable
private fun SectionTitle(title: String) {
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.semantics { heading() },
)
}
@Composable
private fun GroupDivider() {
HorizontalDivider(
modifier = Modifier.padding(start = 54.dp),
color = LocalVniDropColors.current.borderDefault.copy(alpha = 0.7f),
)
}
@Composable
private fun DiagnosticEndpoint(endpointId: String) {
Text(
stringResource(Res.string.saved_devices_endpoint, shortEndpoint(endpointId)),
style = MaterialTheme.typography.bodySmall,
color = LocalVniDropColors.current.foregroundLighter,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@Composable
private fun SavedDevicesEmptyState(modifier: Modifier = Modifier) {
val colors = LocalVniDropColors.current
Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Column(
modifier = Modifier.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
PlatformIcon(AppIcon.ShieldCheck, 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),
style = MaterialTheme.typography.bodyMedium,
color = colors.foregroundLight,
textAlign = TextAlign.Center,
)
}
}
}
@Composable
private fun InlineLoadFailure(onRetry: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(stringResource(Res.string.saved_devices_load_failed), Modifier.weight(1f), color = LocalVniDropColors.current.foregroundLight)
TextButton(onClick = onRetry) { Text(stringResource(Res.string.button_retry)) }
}
}
@Composable
private fun SavedDeviceModel.displayName(): String = localLabel?.takeIf(String::isNotBlank)
?: remoteDisplayName?.takeIf(String::isNotBlank)
?: stringResource(Res.string.saved_devices_unnamed)
private fun shortEndpoint(endpointId: String): String =
if (endpointId.length <= 20) endpointId else endpointId.take(16) + ""

View File

@@ -0,0 +1,287 @@
package com.vnidrop.app.feature.saveddevices
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.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.TargetedTransferStateModel
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.SecondaryButton
import com.vnidrop.app.ui.icons.AppIcon
import com.vnidrop.app.ui.icons.PlatformIcon
import com.vnidrop.app.ui.state.formatBytes
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.saved_devices_transfer_cancel
import vnidrop.shared.generated.resources.saved_devices_transfer_delete
import vnidrop.shared.generated.resources.saved_devices_transfer_direction_incoming
import vnidrop.shared.generated.resources.saved_devices_transfer_direction_outgoing
import vnidrop.shared.generated.resources.saved_devices_transfer_files
import vnidrop.shared.generated.resources.saved_devices_transfer_progress
import vnidrop.shared.generated.resources.saved_devices_transfer_receive
import vnidrop.shared.generated.resources.saved_devices_transfer_resume
import vnidrop.shared.generated.resources.saved_devices_transfers_title
import vnidrop.shared.generated.resources.saved_devices_endpoint
import vnidrop.shared.generated.resources.saved_devices_unnamed
import vnidrop.shared.generated.resources.status_approved
import vnidrop.shared.generated.resources.status_awaiting_approval
import vnidrop.shared.generated.resources.status_cancelled
import vnidrop.shared.generated.resources.status_completed
import vnidrop.shared.generated.resources.status_connecting
import vnidrop.shared.generated.resources.status_declined
import vnidrop.shared.generated.resources.status_failed
import vnidrop.shared.generated.resources.status_interrupted
import vnidrop.shared.generated.resources.status_offering
import vnidrop.shared.generated.resources.status_preparing
import vnidrop.shared.generated.resources.status_transferring
enum class SavedDeviceTransferAction {
Receive,
Resume,
Cancel,
Delete,
}
internal enum class TargetedTransferPresentation {
CompactRow,
DesktopRow,
}
internal fun LazyListScope.targetedTransferItems(
transfers: List<SavedDeviceTransferItem>,
busyTransferIds: Set<String>,
onAction: (String, SavedDeviceTransferAction) -> Unit,
presentation: TargetedTransferPresentation = TargetedTransferPresentation.CompactRow,
) {
item(key = "targeted-transfer-title") {
Text(
text = stringResource(Res.string.saved_devices_transfers_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 8.dp).semantics { heading() },
)
}
if (transfers.isNotEmpty()) {
itemsIndexed(transfers, key = { _, transfer -> "targeted-${transfer.id}" }) { index, transfer ->
if (index > 0) {
HorizontalDivider(color = LocalVniDropColors.current.borderDefault.copy(alpha = 0.7f))
}
TargetedTransferRow(
transfer = transfer,
busy = transfer.id in busyTransferIds,
presentation = presentation,
onAction = { action -> onAction(transfer.id, action) },
)
}
}
}
@Composable
private fun TargetedTransferRow(
transfer: SavedDeviceTransferItem,
busy: Boolean,
presentation: TargetedTransferPresentation,
onAction: (SavedDeviceTransferAction) -> Unit,
) {
val colors = LocalVniDropColors.current
val progress = if (transfer.totalSize == 0UL) 0f else {
(transfer.verifiedBytes.toDouble() / transfer.totalSize.toDouble()).coerceIn(0.0, 1.0).toFloat()
}
Column(
Modifier.fillMaxWidth().padding(
horizontal = if (presentation == TargetedTransferPresentation.DesktopRow) 4.dp else 0.dp,
vertical = 14.dp,
),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
PlatformIcon(
if (transfer.direction == SavedDeviceTransferDirection.Incoming) AppIcon.Download else AppIcon.Send,
contentDescription = null,
tint = colors.foregroundLight,
modifier = Modifier.size(22.dp),
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
transfer.transferName.ifBlank { stringResource(Res.string.saved_devices_unnamed) },
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
stringResource(
if (transfer.direction == SavedDeviceTransferDirection.Incoming) {
Res.string.saved_devices_transfer_direction_incoming
} else {
Res.string.saved_devices_transfer_direction_outgoing
},
transfer.peerDisplayName?.takeIf(String::isNotBlank)
?: stringResource(Res.string.saved_devices_unnamed),
),
style = MaterialTheme.typography.bodyMedium,
color = colors.foregroundLight,
)
}
if (busy) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "${stringResource(transfer.state.labelResource())} · " +
stringResource(
Res.string.saved_devices_transfer_files,
transfer.fileCount.toString(),
formatBytes(transfer.totalSize),
),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
color = colors.foregroundLighter,
)
if (presentation == TargetedTransferPresentation.DesktopRow) {
Text(
text = stringResource(
Res.string.saved_devices_endpoint,
shortTransferEndpoint(transfer.peerEndpointId),
),
style = MaterialTheme.typography.bodySmall,
color = colors.foregroundLighter,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (presentation == TargetedTransferPresentation.CompactRow) {
Text(
text = stringResource(Res.string.saved_devices_endpoint, shortTransferEndpoint(transfer.peerEndpointId)),
style = MaterialTheme.typography.bodySmall,
color = colors.foregroundLighter,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (
transfer.direction == SavedDeviceTransferDirection.Incoming &&
transfer.totalSize > 0UL &&
transfer.state.showsProgress()
) {
LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
Text(
stringResource(
Res.string.saved_devices_transfer_progress,
formatBytes(transfer.verifiedBytes),
formatBytes(transfer.totalSize),
),
style = MaterialTheme.typography.labelSmall,
color = colors.foregroundLighter,
)
}
TransferActions(transfer, busy, onAction)
}
}
@Composable
private fun TransferActions(
transfer: SavedDeviceTransferItem,
busy: Boolean,
onAction: (SavedDeviceTransferAction) -> Unit,
) {
val incoming = transfer.direction == SavedDeviceTransferDirection.Incoming
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
when {
incoming && transfer.state == TargetedTransferStateModel.Approved -> PrimaryButton(
stringResource(Res.string.saved_devices_transfer_receive),
{ onAction(SavedDeviceTransferAction.Receive) },
enabled = !busy,
leadingIcon = { PlatformIcon(AppIcon.Download, contentDescription = null, modifier = Modifier.size(18.dp)) },
)
incoming && transfer.state == TargetedTransferStateModel.Interrupted -> PrimaryButton(
stringResource(Res.string.saved_devices_transfer_resume),
{ onAction(SavedDeviceTransferAction.Resume) },
enabled = !busy,
leadingIcon = { PlatformIcon(AppIcon.Sync, contentDescription = null, modifier = Modifier.size(18.dp)) },
)
}
when {
transfer.state.isCancellable() -> SecondaryButton(
stringResource(Res.string.saved_devices_transfer_cancel),
{ onAction(SavedDeviceTransferAction.Cancel) },
enabled = !busy,
leadingIcon = { PlatformIcon(AppIcon.StopCircle, contentDescription = null, modifier = Modifier.size(18.dp)) },
)
transfer.state.isDeletable() -> SecondaryButton(
stringResource(Res.string.saved_devices_transfer_delete),
{ onAction(SavedDeviceTransferAction.Delete) },
enabled = !busy,
leadingIcon = { PlatformIcon(AppIcon.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) },
)
}
}
}
private fun TargetedTransferStateModel.labelResource(): StringResource = when (this) {
TargetedTransferStateModel.Preparing -> Res.string.status_preparing
TargetedTransferStateModel.Offering -> Res.string.status_offering
TargetedTransferStateModel.AwaitingApproval -> Res.string.status_awaiting_approval
TargetedTransferStateModel.Approved -> Res.string.status_approved
TargetedTransferStateModel.Connecting -> Res.string.status_connecting
TargetedTransferStateModel.Transferring -> Res.string.status_transferring
TargetedTransferStateModel.Interrupted -> Res.string.status_interrupted
TargetedTransferStateModel.Completed -> Res.string.status_completed
TargetedTransferStateModel.Declined -> Res.string.status_declined
TargetedTransferStateModel.Cancelled -> Res.string.status_cancelled
TargetedTransferStateModel.Failed -> Res.string.status_failed
TargetedTransferStateModel.Deleted -> Res.string.status_cancelled
}
private fun TargetedTransferStateModel.showsProgress(): Boolean = this in setOf(
TargetedTransferStateModel.Connecting,
TargetedTransferStateModel.Transferring,
TargetedTransferStateModel.Interrupted,
TargetedTransferStateModel.Completed,
)
private fun TargetedTransferStateModel.isCancellable(): Boolean = this in setOf(
TargetedTransferStateModel.Preparing,
TargetedTransferStateModel.Offering,
TargetedTransferStateModel.AwaitingApproval,
TargetedTransferStateModel.Approved,
TargetedTransferStateModel.Connecting,
TargetedTransferStateModel.Transferring,
TargetedTransferStateModel.Interrupted,
)
private fun TargetedTransferStateModel.isDeletable(): Boolean = this in setOf(
TargetedTransferStateModel.Completed,
TargetedTransferStateModel.Declined,
TargetedTransferStateModel.Cancelled,
TargetedTransferStateModel.Failed,
)
private fun shortTransferEndpoint(endpointId: String): String =
if (endpointId.length <= 20) endpointId else endpointId.take(16) + ""

View File

@@ -25,6 +25,8 @@ internal fun SavedDevicesRoute(
onDeclineEligible = viewModel::declineEligible, onDeclineEligible = viewModel::declineEligible,
onAcceptIncoming = viewModel::acceptIncoming, onAcceptIncoming = viewModel::acceptIncoming,
onDeclineIncoming = viewModel::declineIncoming, onDeclineIncoming = viewModel::declineIncoming,
onAcceptOffer = viewModel::acceptTargetedOffer,
onDeclineOffer = viewModel::declineTargetedOffer,
onSend = { peerEndpointId -> onSend = { peerEndpointId ->
state.savedDevices.firstOrNull { it.endpointId == peerEndpointId } state.savedDevices.firstOrNull { it.endpointId == peerEndpointId }
?.let { targetedDraftViewModel.openTargeted(it, unnamedDeviceName) } ?.let { targetedDraftViewModel.openTargeted(it, unnamedDeviceName) }
@@ -32,6 +34,14 @@ internal fun SavedDevicesRoute(
onOpenLabel = viewModel::openLabelEditor, onOpenLabel = viewModel::openLabelEditor,
onForget = viewModel::forget, onForget = viewModel::forget,
onBlock = viewModel::block, onBlock = viewModel::block,
onTransferAction = { transferId, action ->
when (action) {
SavedDeviceTransferAction.Receive -> viewModel.receiveTargetedTransfer(transferId)
SavedDeviceTransferAction.Resume -> viewModel.resumeTargetedTransfer(transferId)
SavedDeviceTransferAction.Cancel -> viewModel.cancelTargetedTransfer(transferId)
SavedDeviceTransferAction.Delete -> viewModel.deleteTargetedTransfer(transferId)
}
},
onLabelDraftChanged = viewModel::setLabelDraft, onLabelDraftChanged = viewModel::setLabelDraft,
onSaveLabel = viewModel::saveLabel, onSaveLabel = viewModel::saveLabel,
onClearLabel = viewModel::clearLabel, onClearLabel = viewModel::clearLabel,

View File

@@ -1,52 +1,28 @@
package com.vnidrop.app.feature.saveddevices package com.vnidrop.app.feature.saveddevices
import androidx.compose.foundation.BorderStroke
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
import androidx.compose.foundation.layout.Row
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.height 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.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField 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
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.DeviceRelationshipModel
import com.vnidrop.app.core.DeviceRelationshipStateModel
import com.vnidrop.app.core.PairingEligibilityModel
import com.vnidrop.app.core.SavedDeviceModel
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.SecondaryButton import com.vnidrop.app.ui.components.SecondaryButton
import com.vnidrop.app.ui.icons.AppIcon import com.vnidrop.app.ui.icons.AppIcon
import com.vnidrop.app.ui.icons.PlatformIcon import com.vnidrop.app.ui.icons.PlatformIcon
@@ -56,20 +32,7 @@ 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_accept_pairing_action
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_description import vnidrop.shared.generated.resources.saved_devices_description
import vnidrop.shared.generated.resources.saved_devices_eligibility_title
import vnidrop.shared.generated.resources.saved_devices_empty
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_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
@@ -77,14 +40,6 @@ import vnidrop.shared.generated.resources.saved_devices_label_title
import vnidrop.shared.generated.resources.saved_devices_list_title import vnidrop.shared.generated.resources.saved_devices_list_title
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_loading import vnidrop.shared.generated.resources.saved_devices_loading
import vnidrop.shared.generated.resources.saved_devices_more_actions
import vnidrop.shared.generated.resources.saved_devices_no_pending
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_title
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_unnamed
@Composable @Composable
internal fun SavedDevicesScreen( internal fun SavedDevicesScreen(
@@ -96,50 +51,58 @@ internal fun SavedDevicesScreen(
onDeclineEligible: (String) -> Unit, onDeclineEligible: (String) -> Unit,
onAcceptIncoming: (String) -> Unit, onAcceptIncoming: (String) -> Unit,
onDeclineIncoming: (String) -> Unit, onDeclineIncoming: (String) -> Unit,
onAcceptOffer: (String) -> Unit,
onDeclineOffer: (String) -> Unit,
onSend: (String) -> Unit, onSend: (String) -> Unit,
onOpenLabel: (String) -> Unit, onOpenLabel: (String) -> Unit,
onForget: (String) -> Unit, onForget: (String) -> Unit,
onBlock: (String) -> Unit, onBlock: (String) -> Unit,
onTransferAction: (String, SavedDeviceTransferAction) -> Unit,
onLabelDraftChanged: (String) -> Unit, onLabelDraftChanged: (String) -> Unit,
onSaveLabel: () -> Unit, onSaveLabel: () -> Unit,
onClearLabel: () -> Unit, onClearLabel: () -> Unit,
onDismissLabel: () -> Unit, onDismissLabel: () -> Unit,
) { ) {
val hasContent = state.eligibilities.isNotEmpty() || state.pendingRelationships.isNotEmpty() || state.savedDevices.isNotEmpty() val hasContent = state.eligibilities.isNotEmpty() || state.pendingRelationships.isNotEmpty() ||
state.savedDevices.isNotEmpty() || state.targetedTransfers.isNotEmpty() ||
state.targetedOffers.pending.isNotEmpty()
Column( Column(
modifier = modifier modifier = modifier.fillMaxSize().statusBarsPadding().padding(top = 16.dp),
.fillMaxSize()
.statusBarsPadding()
.padding(top = 20.dp),
) { ) {
SavedDevicesHeader(Modifier.padding(horizontal = if (windowClass == WindowClass.Desktop) 24.dp else 16.dp)) SavedDevicesHeader(Modifier.padding(horizontal = if (windowClass == WindowClass.Desktop) 24.dp else 16.dp))
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(20.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))
windowClass == WindowClass.Desktop -> DesktopSavedDevicesContent( windowClass == WindowClass.Desktop -> DesktopSavedDevicesHub(
state = state, state = state,
onRetry = onRetry, onRetry = onRetry,
onRememberEligible = onRememberEligible, onRememberEligible = onRememberEligible,
onDeclineEligible = onDeclineEligible, onDeclineEligible = onDeclineEligible,
onAcceptIncoming = onAcceptIncoming, onAcceptIncoming = onAcceptIncoming,
onDeclineIncoming = onDeclineIncoming, onDeclineIncoming = onDeclineIncoming,
onAcceptOffer = onAcceptOffer,
onDeclineOffer = onDeclineOffer,
onSend = onSend, onSend = onSend,
onOpenLabel = onOpenLabel, onOpenLabel = onOpenLabel,
onForget = onForget, onForget = onForget,
onBlock = onBlock, onBlock = onBlock,
onTransferAction = onTransferAction,
) )
else -> CompactSavedDevicesContent( else -> CompactSavedDevicesHub(
state = state, state = state,
onRetry = onRetry, onRetry = onRetry,
onRememberEligible = onRememberEligible, onRememberEligible = onRememberEligible,
onDeclineEligible = onDeclineEligible, onDeclineEligible = onDeclineEligible,
onAcceptIncoming = onAcceptIncoming, onAcceptIncoming = onAcceptIncoming,
onDeclineIncoming = onDeclineIncoming, onDeclineIncoming = onDeclineIncoming,
onAcceptOffer = onAcceptOffer,
onDeclineOffer = onDeclineOffer,
onSend = onSend, onSend = onSend,
onOpenLabel = onOpenLabel, onOpenLabel = onOpenLabel,
onForget = onForget, onForget = onForget,
onBlock = onBlock, onBlock = onBlock,
onTransferAction = onTransferAction,
) )
} }
} }
@@ -155,436 +118,21 @@ internal fun SavedDevicesScreen(
@Composable @Composable
private fun SavedDevicesHeader(modifier: Modifier = Modifier) { private fun SavedDevicesHeader(modifier: Modifier = Modifier) {
Column(modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) { 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.headlineLarge, style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.semantics { heading() }, modifier = Modifier.semantics { heading() },
) )
Text( Text(
text = stringResource(Res.string.saved_devices_description), text = stringResource(Res.string.saved_devices_description),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyMedium,
color = LocalVniDropColors.current.foregroundLight, color = LocalVniDropColors.current.foregroundLight,
) )
} }
} }
@Composable
private fun CompactSavedDevicesContent(
state: SavedDevicesState,
onRetry: () -> Unit,
onRememberEligible: (String) -> Unit,
onDeclineEligible: (String) -> Unit,
onAcceptIncoming: (String) -> Unit,
onDeclineIncoming: (String) -> Unit,
onSend: (String) -> Unit,
onOpenLabel: (String) -> Unit,
onForget: (String) -> Unit,
onBlock: (String) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (state.isLoading) item(key = "loading") { LinearProgressIndicator(Modifier.fillMaxWidth()) }
if (state.loadFailed) item(key = "load-failed") { InlineLoadFailure(onRetry) }
pairingItems(
state = state,
onRememberEligible = onRememberEligible,
onDeclineEligible = onDeclineEligible,
onAcceptIncoming = onAcceptIncoming,
onDeclineIncoming = onDeclineIncoming,
)
item(key = "saved-title") { SectionTitle(stringResource(Res.string.saved_devices_list_title)) }
if (state.savedDevices.isEmpty()) {
item(key = "empty") { SavedDevicesEmptyCard() }
} else {
items(state.savedDevices, key = { "saved-${it.endpointId}" }) { device ->
SavedDeviceCard(
device = device,
busy = device.endpointId in state.busyPeerIds,
onSend = { onSend(device.endpointId) },
onLabel = { onOpenLabel(device.endpointId) },
onForget = { onForget(device.endpointId) },
onBlock = { onBlock(device.endpointId) },
)
}
}
}
}
@Composable
private fun DesktopSavedDevicesContent(
state: SavedDevicesState,
onRetry: () -> Unit,
onRememberEligible: (String) -> Unit,
onDeclineEligible: (String) -> Unit,
onAcceptIncoming: (String) -> Unit,
onDeclineIncoming: (String) -> Unit,
onSend: (String) -> Unit,
onOpenLabel: (String) -> Unit,
onForget: (String) -> Unit,
onBlock: (String) -> Unit,
) {
Row(
modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp),
horizontalArrangement = Arrangement.spacedBy(20.dp),
) {
LazyColumn(
modifier = Modifier.weight(0.8f).fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 24.dp),
) {
if (state.isLoading) item(key = "loading") { LinearProgressIndicator(Modifier.fillMaxWidth()) }
if (state.loadFailed) item(key = "load-failed") { InlineLoadFailure(onRetry) }
pairingItems(
state = state,
onRememberEligible = onRememberEligible,
onDeclineEligible = onDeclineEligible,
onAcceptIncoming = onAcceptIncoming,
onDeclineIncoming = onDeclineIncoming,
)
if (state.eligibilities.isEmpty() && state.pendingRelationships.isEmpty()) {
item(key = "no-attention") {
DesktopStatusCard()
}
}
}
LazyColumn(
modifier = Modifier.weight(1.2f).fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 24.dp),
) {
item(key = "saved-title") { SectionTitle(stringResource(Res.string.saved_devices_list_title)) }
if (state.savedDevices.isEmpty()) {
item(key = "empty") { SavedDevicesEmptyCard() }
} else {
items(state.savedDevices, key = { "saved-${it.endpointId}" }) { device ->
SavedDeviceCard(
device = device,
busy = device.endpointId in state.busyPeerIds,
onSend = { onSend(device.endpointId) },
onLabel = { onOpenLabel(device.endpointId) },
onForget = { onForget(device.endpointId) },
onBlock = { onBlock(device.endpointId) },
)
}
}
}
}
}
private fun androidx.compose.foundation.lazy.LazyListScope.pairingItems(
state: SavedDevicesState,
onRememberEligible: (String) -> Unit,
onDeclineEligible: (String) -> Unit,
onAcceptIncoming: (String) -> Unit,
onDeclineIncoming: (String) -> Unit,
) {
if (state.eligibilities.isNotEmpty()) {
item(key = "eligibility-title") {
SectionTitle(stringResource(Res.string.saved_devices_eligibility_title))
}
items(state.eligibilities, key = { "eligibility-${it.peerEndpointId}" }) { eligibility ->
EligibilityCard(
eligibility = eligibility,
busy = eligibility.peerEndpointId in state.busyPeerIds,
onRemember = { onRememberEligible(eligibility.peerEndpointId) },
onDecline = { onDeclineEligible(eligibility.peerEndpointId) },
)
}
}
if (state.pendingRelationships.isNotEmpty()) {
item(key = "pending-title") {
SectionTitle(stringResource(Res.string.saved_devices_pending_title))
}
items(state.pendingRelationships, key = { "pending-${it.remoteEndpointId}" }) { relationship ->
PendingPairingCard(
relationship = relationship,
remoteDisplayName = state.eligibilities
.firstOrNull { it.peerEndpointId == relationship.remoteEndpointId }
?.remoteDisplayName,
busy = relationship.remoteEndpointId in state.busyPeerIds,
onAccept = { onAcceptIncoming(relationship.remoteEndpointId) },
onDecline = { onDeclineIncoming(relationship.remoteEndpointId) },
)
}
}
}
@Composable
private fun SectionTitle(title: String) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 4.dp).semantics { heading() },
)
}
@Composable
private fun EligibilityCard(
eligibility: PairingEligibilityModel,
busy: Boolean,
onRemember: () -> Unit,
onDecline: () -> Unit,
) {
PairingCard(
name = eligibility.remoteDisplayName,
endpointId = eligibility.peerEndpointId,
status = stringResource(Res.string.saved_devices_eligibility_title),
busy = busy,
actions = {
PrimaryButton(stringResource(Res.string.saved_devices_remember_action), onRemember, enabled = !busy)
SecondaryButton(stringResource(Res.string.saved_devices_decline_action), onDecline, enabled = !busy)
},
)
}
@Composable
private fun PendingPairingCard(
relationship: DeviceRelationshipModel,
remoteDisplayName: String?,
busy: Boolean,
onAccept: () -> Unit,
onDecline: () -> Unit,
) {
PairingCard(
name = remoteDisplayName,
endpointId = relationship.remoteEndpointId,
status = stringResource(
when (relationship.state) {
DeviceRelationshipStateModel.PendingIncoming -> Res.string.saved_devices_pending_incoming
else -> Res.string.saved_devices_pending_outgoing
},
),
busy = busy,
actions = if (relationship.state == DeviceRelationshipStateModel.PendingIncoming) {
{
PrimaryButton(stringResource(Res.string.saved_devices_accept_pairing_action), onAccept, enabled = !busy)
SecondaryButton(stringResource(Res.string.saved_devices_decline_action), onDecline, enabled = !busy)
}
} else {
null
},
)
}
@Composable
private fun PairingCard(
name: String?,
endpointId: String,
status: String,
busy: Boolean,
actions: (@Composable RowScope.() -> Unit)?,
) {
val colors = LocalVniDropColors.current
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface200),
border = BorderStroke(1.dp, colors.borderDefault.copy(alpha = 0.72f)),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
PlatformIcon(AppIcon.Shield, contentDescription = null, tint = colors.brandLink, modifier = Modifier.size(24.dp))
Column(Modifier.weight(1f)) {
Text(
name?.takeIf(String::isNotBlank) ?: stringResource(Res.string.saved_devices_unnamed),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Text(status, style = MaterialTheme.typography.bodyMedium, color = colors.foregroundLight)
}
if (busy) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
}
DiagnosticEndpoint(endpointId)
if (actions != null) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), content = actions)
}
}
}
}
private enum class DeviceDestructiveAction { Forget, Block }
@Composable
private fun SavedDeviceCard(
device: SavedDeviceModel,
busy: Boolean,
onSend: () -> Unit,
onLabel: () -> Unit,
onForget: () -> Unit,
onBlock: () -> Unit,
) {
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) }
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(18.dp),
colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface200),
border = BorderStroke(1.dp, colors.borderDefault.copy(alpha = 0.72f)),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(contentAlignment = Alignment.Center) {
Card(
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(containerColor = colors.backgroundSelection),
) {
PlatformIcon(
AppIcon.ShieldCheck,
contentDescription = null,
tint = colors.brandLink,
modifier = Modifier.padding(10.dp).size(24.dp),
)
}
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
device.remoteDisplayName
?.takeIf { device.localLabel?.isNotBlank() == true && it.isNotBlank() }
?.let { authenticatedName ->
Text(
stringResource(Res.string.saved_devices_authenticated_name, authenticatedName),
style = MaterialTheme.typography.bodySmall,
color = colors.foregroundLight,
)
}
}
if (busy) {
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
} else {
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) },
)
}
}
}
}
DiagnosticEndpoint(device.endpointId)
PrimaryButton(
text = stringResource(Res.string.saved_devices_send_action),
onClick = onSend,
modifier = Modifier.fillMaxWidth(),
enabled = !busy,
leadingIcon = { PlatformIcon(AppIcon.Send, contentDescription = null, modifier = Modifier.size(18.dp)) },
)
}
}
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))
}
},
)
}
}
@Composable
private fun DiagnosticEndpoint(endpointId: String) {
Text(
text = stringResource(Res.string.saved_devices_endpoint, shortEndpoint(endpointId)),
style = MaterialTheme.typography.bodySmall,
color = LocalVniDropColors.current.foregroundLighter,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@Composable
private fun SavedDevicesEmptyCard() {
val colors = LocalVniDropColors.current
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(18.dp),
colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface200),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
PlatformIcon(AppIcon.ShieldCheck, contentDescription = null, tint = colors.foregroundLighter, modifier = Modifier.size(32.dp))
Text(
stringResource(Res.string.saved_devices_empty),
style = MaterialTheme.typography.bodyLarge,
color = colors.foregroundLight,
)
}
}
}
@Composable
private fun DesktopStatusCard() {
val colors = LocalVniDropColors.current
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface200),
shape = RoundedCornerShape(16.dp),
) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
PlatformIcon(AppIcon.Check, contentDescription = null, tint = colors.brandLink, modifier = Modifier.size(24.dp))
Text(stringResource(Res.string.saved_devices_pending_title), style = MaterialTheme.typography.titleMedium)
Text(stringResource(Res.string.saved_devices_no_pending), color = colors.foregroundLight)
}
}
}
@Composable @Composable
private fun SavedDevicesLoading(modifier: Modifier = Modifier) { private fun SavedDevicesLoading(modifier: Modifier = Modifier) {
Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
@@ -606,23 +154,6 @@ private fun SavedDevicesLoadFailure(onRetry: () -> Unit, modifier: Modifier = Mo
} }
} }
@Composable
private fun InlineLoadFailure(onRetry: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = LocalVniDropColors.current.backgroundSurface200),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(stringResource(Res.string.saved_devices_load_failed), Modifier.weight(1f))
TextButton(onClick = onRetry) { Text(stringResource(Res.string.button_retry)) }
}
}
}
@Composable @Composable
private fun SavedDeviceLabelDialog( private fun SavedDeviceLabelDialog(
visible: Boolean, visible: Boolean,
@@ -649,18 +180,10 @@ private fun SavedDeviceLabelDialog(
TextButton(onClick = onSave) { Text(stringResource(Res.string.saved_devices_label_save)) } TextButton(onClick = onSave) { Text(stringResource(Res.string.saved_devices_label_save)) }
}, },
dismissButton = { dismissButton = {
Row { androidx.compose.foundation.layout.Row {
TextButton(onClick = onClear) { Text(stringResource(Res.string.saved_devices_label_clear)) } TextButton(onClick = onClear) { Text(stringResource(Res.string.saved_devices_label_clear)) }
TextButton(onClick = onDismiss) { Text(stringResource(Res.string.button_cancel)) } TextButton(onClick = onDismiss) { Text(stringResource(Res.string.button_cancel)) }
} }
}, },
) )
} }
@Composable
private fun SavedDeviceModel.displayName(): String = localLabel?.takeIf(String::isNotBlank)
?: remoteDisplayName?.takeIf(String::isNotBlank)
?: stringResource(Res.string.saved_devices_unnamed)
private fun shortEndpoint(endpointId: String): String =
if (endpointId.length <= 20) endpointId else endpointId.take(16) + ""

View File

@@ -6,8 +6,14 @@ import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.CoreSignal import com.vnidrop.app.core.CoreSignal
import com.vnidrop.app.core.DeviceRelationshipModel import com.vnidrop.app.core.DeviceRelationshipModel
import com.vnidrop.app.core.DeviceRelationshipStateModel import com.vnidrop.app.core.DeviceRelationshipStateModel
import com.vnidrop.app.core.FileSystemService
import com.vnidrop.app.core.PairingEligibilityModel import com.vnidrop.app.core.PairingEligibilityModel
import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.SavedDeviceModel import com.vnidrop.app.core.SavedDeviceModel
import com.vnidrop.app.core.TargetedOfferResponseModel
import com.vnidrop.app.core.TargetedTransferModel
import com.vnidrop.app.core.TargetedTransferStateModel
import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessage import com.vnidrop.app.ui.feedback.UiMessage
import com.vnidrop.app.ui.feedback.UiMessageController import com.vnidrop.app.ui.feedback.UiMessageController
import com.vnidrop.app.ui.feedback.UiMessageTone import com.vnidrop.app.ui.feedback.UiMessageTone
@@ -15,11 +21,16 @@ import com.vnidrop.app.ui.feedback.UiText
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.receive_completed
import vnidrop.shared.generated.resources.saved_devices_blocked import vnidrop.shared.generated.resources.saved_devices_blocked
import vnidrop.shared.generated.resources.saved_devices_forgotten import vnidrop.shared.generated.resources.saved_devices_forgotten
import vnidrop.shared.generated.resources.saved_devices_labeled import vnidrop.shared.generated.resources.saved_devices_labeled
@@ -30,23 +41,41 @@ data class SavedDevicesState(
val eligibilities: List<PairingEligibilityModel> = emptyList(), val eligibilities: List<PairingEligibilityModel> = emptyList(),
val pendingRelationships: List<DeviceRelationshipModel> = emptyList(), val pendingRelationships: List<DeviceRelationshipModel> = emptyList(),
val savedDevices: List<SavedDeviceModel> = emptyList(), val savedDevices: List<SavedDeviceModel> = emptyList(),
val targetedTransfers: List<SavedDeviceTransferItem> = emptyList(),
val pairingPrompt: PairingPromptState = PairingPromptState(),
val targetedOffers: TargetedOfferState = TargetedOfferState(),
val busyPeerIds: Set<String> = emptySet(), val busyPeerIds: Set<String> = emptySet(),
val busyTransferIds: Set<String> = emptySet(),
val labelingPeerId: String? = null, val labelingPeerId: String? = null,
val labelDraft: String = "", val labelDraft: String = "",
) )
/**
* Product-level Saved-device experience. Callers observe one snapshot and issue
* named commands; pairing, direct offers, history, and receive destinations stay internal.
*/
class SavedDevicesViewModel( class SavedDevicesViewModel(
private val repository: CoreGateway, private val repository: CoreGateway,
private val fileSystemService: FileSystemService,
private val preferencesRepository: PreferencesRepository,
private val messages: UiMessageController, private val messages: UiMessageController,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(SavedDevicesState()) private val _state = MutableStateFlow(SavedDevicesState())
val state: StateFlow<SavedDevicesState> = _state.asStateFlow() val state: StateFlow<SavedDevicesState> = _state.asStateFlow()
private val refreshMutex = Mutex()
private val dismissedEligibility = mutableSetOf<String>()
private var receiveFolder: ReceiveFolder? = null
init { init {
viewModelScope.launch { viewModelScope.launch {
repository.state.map { it.isInitialized } combine(
preferencesRepository.preferences,
repository.state.map { it.isInitialized },
) { preferences, initialized -> preferences.receiveFolder to initialized }
.distinctUntilChanged() .distinctUntilChanged()
.collect { initialized -> .collectLatest { (configuredFolder, initialized) ->
receiveFolder = fileSystemService.effectiveReceiveFolder(configuredFolder)
if (initialized) refresh() if (initialized) refresh()
} }
} }
@@ -70,6 +99,44 @@ class SavedDevicesViewModel(
viewModelScope.launch { refresh() } viewModelScope.launch { refresh() }
} }
fun acceptPairingPrompt() {
val prompt = _state.value.pairingPrompt.prompt ?: return
if (_state.value.pairingPrompt.busy) return
_state.update { it.copy(pairingPrompt = it.pairingPrompt.copy(busy = true)) }
viewModelScope.launch {
val result = when (prompt) {
is PairingPrompt.Eligibility -> repository.requestSavedDevicePairing(prompt.peerEndpointId)
is PairingPrompt.IncomingRequest -> repository.respondToDevicePairing(prompt.peerEndpointId, true)
}
_state.update { it.copy(pairingPrompt = it.pairingPrompt.copy(busy = false)) }
result.fold(onSuccess = { refresh() }, onFailure = messages::error)
}
}
fun declinePairingPrompt() {
val prompt = _state.value.pairingPrompt.prompt ?: return
if (_state.value.pairingPrompt.busy) return
_state.update { it.copy(pairingPrompt = it.pairingPrompt.copy(busy = true)) }
viewModelScope.launch {
val result = when (prompt) {
is PairingPrompt.Eligibility -> repository.declinePairingEligibility(prompt.peerEndpointId)
is PairingPrompt.IncomingRequest -> repository.respondToDevicePairing(prompt.peerEndpointId, false)
}
_state.update { it.copy(pairingPrompt = it.pairingPrompt.copy(busy = false)) }
result.fold(onSuccess = { refresh() }, onFailure = messages::error)
}
}
fun dismissPairingPrompt() {
val prompt = _state.value.pairingPrompt.prompt ?: return
if (prompt is PairingPrompt.Eligibility) dismissedEligibility += prompt.peerEndpointId
_state.update { it.copy(pairingPrompt = it.pairingPrompt.copy(prompt = null)) }
}
fun acceptTargetedOffer(transferId: String) = respondToTargetedOffer(transferId, accepted = true)
fun declineTargetedOffer(transferId: String) = respondToTargetedOffer(transferId, accepted = false)
fun rememberEligible(peerEndpointId: String) = mutatePeer(peerEndpointId) { fun rememberEligible(peerEndpointId: String) = mutatePeer(peerEndpointId) {
repository.requestSavedDevicePairing(peerEndpointId).map { } repository.requestSavedDevicePairing(peerEndpointId).map { }
} }
@@ -86,6 +153,18 @@ class SavedDevicesViewModel(
repository.respondToDevicePairing(peerEndpointId, accepted = false).map { } repository.respondToDevicePairing(peerEndpointId, accepted = false).map { }
} }
fun receiveTargetedTransfer(transferId: String) = runTransfer(transferId, resume = false)
fun resumeTargetedTransfer(transferId: String) = runTransfer(transferId, resume = true)
fun cancelTargetedTransfer(transferId: String) = mutateTransfer(transferId) {
repository.cancelTargetedTransfer(transferId)
}
fun deleteTargetedTransfer(transferId: String) = mutateTransfer(transferId) {
repository.deleteTargetedTransfer(transferId)
}
fun openLabelEditor(peerEndpointId: String) { fun openLabelEditor(peerEndpointId: String) {
val current = _state.value.savedDevices.firstOrNull { it.endpointId == peerEndpointId } val current = _state.value.savedDevices.firstOrNull { it.endpointId == peerEndpointId }
_state.update { _state.update {
@@ -134,46 +213,161 @@ class SavedDevicesViewModel(
} }
} }
private fun respondToTargetedOffer(transferId: String, accepted: Boolean) {
if (transferId in _state.value.targetedOffers.respondingIds) return
_state.update {
it.copy(targetedOffers = it.targetedOffers.copy(respondingIds = it.targetedOffers.respondingIds + transferId))
}
viewModelScope.launch {
val response = repository.respondToTargetedOffer(transferId, accepted)
response.fold(
onSuccess = { result ->
if (accepted && result is TargetedOfferResponseModel.Approved) {
pullTargetedTransfer(result.transferId, resume = false).onFailure(messages::error)
}
refresh()
},
onFailure = messages::error,
)
_state.update {
it.copy(targetedOffers = it.targetedOffers.copy(respondingIds = it.targetedOffers.respondingIds - transferId))
}
}
}
private fun runTransfer(transferId: String, resume: Boolean) = mutateTransfer(transferId) {
pullTargetedTransfer(transferId, resume)
}
private suspend fun pullTargetedTransfer(transferId: String, resume: Boolean): Result<Unit> {
val folder = receiveFolder ?: return Result.failure(IllegalStateException())
val sink = fileSystemService.createReceiveOutputSink(folder)
val result = when {
resume && sink != null -> repository.resumeTargetedTransferWithOutputSinkV2(transferId, sink)
resume -> repository.resumeTargetedTransfer(transferId, folder.value)
sink != null -> repository.receiveTargetedTransferWithOutputSinkV2(transferId, sink)
else -> repository.receiveTargetedTransfer(transferId, folder.value)
}
result.onSuccess {
messages.tryShow(UiMessage(UiText.Resource(Res.string.receive_completed), UiMessageTone.Success))
}
return result
}
private fun mutatePeer(peerEndpointId: String, block: suspend () -> Result<*>) { private fun mutatePeer(peerEndpointId: String, block: suspend () -> Result<*>) {
if (peerEndpointId in _state.value.busyPeerIds) return if (peerEndpointId in _state.value.busyPeerIds) return
_state.update { it.copy(busyPeerIds = it.busyPeerIds + peerEndpointId) } _state.update { it.copy(busyPeerIds = it.busyPeerIds + peerEndpointId) }
viewModelScope.launch { viewModelScope.launch {
block().fold( block().fold(onSuccess = { refresh() }, onFailure = messages::error)
onSuccess = { refresh() },
onFailure = messages::error,
)
_state.update { it.copy(busyPeerIds = it.busyPeerIds - peerEndpointId) } _state.update { it.copy(busyPeerIds = it.busyPeerIds - peerEndpointId) }
} }
} }
private fun mutateTransfer(transferId: String, block: suspend () -> Result<Unit>) {
if (transferId in _state.value.busyTransferIds) return
_state.update { it.copy(busyTransferIds = it.busyTransferIds + transferId) }
viewModelScope.launch {
block().fold(onSuccess = { refresh() }, onFailure = messages::error)
_state.update { it.copy(busyTransferIds = it.busyTransferIds - transferId) }
}
}
private suspend fun refresh() { private suspend fun refresh() {
refreshMutex.withLock {
_state.update { it.copy(isLoading = true, loadFailed = false) } _state.update { it.copy(isLoading = true, loadFailed = false) }
val eligibilities = repository.listPairingEligibilities().getOrElse { val eligibilities = repository.listPairingEligibilities().getOrElse {
_state.update { state -> state.copy(isLoading = false, loadFailed = true) } refreshFailed(it)
messages.error(it) return@withLock
return
} }
val relationships = repository.listDeviceRelationships().getOrElse { val relationships = repository.listDeviceRelationships().getOrElse {
_state.update { state -> state.copy(isLoading = false, loadFailed = true) } refreshFailed(it)
messages.error(it) return@withLock
return
} }
val saved = repository.listSavedDevices().getOrElse { val savedDevices = repository.listSavedDevices().getOrElse {
_state.update { state -> state.copy(isLoading = false, loadFailed = true) } refreshFailed(it)
messages.error(it) return@withLock
return }
val pendingOffers = repository.listPendingTargetedOffers().getOrElse {
refreshFailed(it)
return@withLock
}
val targetedTransfers = repository.listTargetedTransfers().getOrElse {
refreshFailed(it)
return@withLock
}
val savedNames = savedDevices.associate { device -> device.endpointId to device.displayNameOrNull() }
.filterValues { it != null }
.mapValues { it.value.orEmpty() }
val pendingRelationships = relationships.filter {
it.state == DeviceRelationshipStateModel.PendingIncoming ||
it.state == DeviceRelationshipStateModel.PendingOutgoing
}.sortedByDescending(DeviceRelationshipModel::updatedAt)
val pairingPrompt = if (_state.value.pairingPrompt.busy) {
_state.value.pairingPrompt
} else {
PairingPromptState(prompt = nextPairingPrompt(pendingRelationships, eligibilities, savedNames))
} }
_state.update { _state.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
loadFailed = false, loadFailed = false,
eligibilities = eligibilities.sortedByDescending(PairingEligibilityModel::createdAt), eligibilities = eligibilities.sortedByDescending(PairingEligibilityModel::createdAt),
pendingRelationships = relationships.filter { pendingRelationships = pendingRelationships,
it.state == DeviceRelationshipStateModel.PendingIncoming || savedDevices = savedDevices.sortedByDescending(SavedDeviceModel::createdAt),
it.state == DeviceRelationshipStateModel.PendingOutgoing targetedTransfers = targetedTransfers
}.sortedByDescending(DeviceRelationshipModel::updatedAt), .filterNot { transfer -> transfer.state == TargetedTransferStateModel.Deleted }
savedDevices = saved.sortedByDescending(SavedDeviceModel::createdAt), .sortedByDescending(TargetedTransferModel::updatedAt)
.map { transfer -> transfer.toExperienceItem(savedNames) },
pairingPrompt = pairingPrompt,
targetedOffers = it.targetedOffers.copy(
pending = pendingOffers.sortedBy { offer -> offer.receivedAt },
senderDisplayNames = savedNames,
),
) )
} }
} }
} }
private fun refreshFailed(error: Throwable) {
_state.update { it.copy(isLoading = false, loadFailed = true) }
messages.error(error)
}
private fun nextPairingPrompt(
relationships: List<DeviceRelationshipModel>,
eligibilities: List<PairingEligibilityModel>,
savedNames: Map<String, String>,
): PairingPrompt? {
val incoming = relationships.firstOrNull { it.state == DeviceRelationshipStateModel.PendingIncoming }
if (incoming != null) {
val name = eligibilities.firstOrNull { it.peerEndpointId == incoming.remoteEndpointId }?.remoteDisplayName
?: savedNames[incoming.remoteEndpointId]
return PairingPrompt.IncomingRequest(incoming.remoteEndpointId, name)
}
return eligibilities.firstOrNull { it.peerEndpointId !in dismissedEligibility }?.let {
PairingPrompt.Eligibility(it.peerEndpointId, it.remoteDisplayName)
}
}
private fun TargetedTransferModel.toExperienceItem(savedNames: Map<String, String>): SavedDeviceTransferItem {
val localEndpointId = repository.state.value.status?.endpointId
val outgoing = senderEndpointId == localEndpointId
val peerEndpointId = if (outgoing) receiverEndpointId else senderEndpointId
return SavedDeviceTransferItem(
id = id,
peerEndpointId = peerEndpointId,
peerDisplayName = savedNames[peerEndpointId],
direction = if (outgoing) SavedDeviceTransferDirection.Outgoing else SavedDeviceTransferDirection.Incoming,
transferName = transferName,
fileCount = fileCount,
totalSize = totalSize,
verifiedBytes = verifiedBytes,
state = state,
createdAt = createdAt,
updatedAt = updatedAt,
)
}
}
private fun SavedDeviceModel.displayNameOrNull(): String? =
localLabel?.takeIf(String::isNotBlank) ?: remoteDisplayName?.takeIf(String::isNotBlank)

View File

@@ -1,146 +0,0 @@
package com.vnidrop.app.feature.saveddevices
import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.CoreSignal
import com.vnidrop.app.core.FileSystemService
import com.vnidrop.app.core.PendingTargetedOfferModel
import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.TargetedOfferResponseModel
import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessage
import com.vnidrop.app.ui.feedback.UiMessageController
import com.vnidrop.app.ui.feedback.UiMessageTone
import com.vnidrop.app.ui.feedback.UiText
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.receive_completed
data class TargetedOfferState(
val pending: List<PendingTargetedOfferModel> = emptyList(),
val senderDisplayNames: Map<String, String> = emptyMap(),
val respondingIds: Set<String> = emptySet(),
) {
val current: PendingTargetedOfferModel?
get() = pending.firstOrNull()
val currentSenderDisplayName: String?
get() = current?.senderEndpointId?.let(senderDisplayNames::get)
}
/**
* Foreground interrupt for pending targeted offers. Approve pulls by transfer id
* through the platform receive destination (MediaStore Downloads sink on Android,
* filesystem path on desktop when no sink is available).
*/
class TargetedOfferCoordinator(
private val repository: CoreGateway,
private val fileSystemService: FileSystemService,
private val preferencesRepository: PreferencesRepository,
private val messages: UiMessageController,
private val scope: CoroutineScope,
) {
private val _state = MutableStateFlow(TargetedOfferState())
val state: StateFlow<TargetedOfferState> = _state.asStateFlow()
private var receiveFolder: ReceiveFolder? = null
init {
scope.launch {
// Same startup race as PairingPromptCoordinator: prefs can load while
// core initialize still holds the lifecycle gate / core is null.
combine(
preferencesRepository.preferences,
repository.state.map { it.isInitialized },
) { preferences, initialized -> preferences to initialized }
.distinctUntilChanged()
.collectLatest { (preferences, initialized) ->
receiveFolder = fileSystemService.effectiveReceiveFolder(preferences.receiveFolder)
if (initialized) refresh()
}
}
scope.launch {
repository.signals.collect { signal ->
when (signal) {
CoreSignal.TargetedTransferChanged -> {
if (repository.state.value.isInitialized) refresh()
}
CoreSignal.PairingChanged,
is CoreSignal.ApprovalChanged,
is CoreSignal.ReceiverHistoryChanged,
is CoreSignal.TransfersChanged -> Unit
}
}
}
}
fun accept(transferId: String) = respond(transferId, accepted = true)
fun decline(transferId: String) = respond(transferId, accepted = false)
private fun respond(transferId: String, accepted: Boolean) {
if (transferId in _state.value.respondingIds) return
_state.update { it.copy(respondingIds = it.respondingIds + transferId) }
scope.launch {
val result = repository.respondToTargetedOffer(transferId, accepted)
result.fold(
onSuccess = { response ->
if (accepted && response is TargetedOfferResponseModel.Approved) {
pull(response.transferId)
}
refresh()
},
onFailure = messages::error,
)
_state.update { it.copy(respondingIds = it.respondingIds - transferId) }
}
}
private suspend fun pull(transferId: String) {
val folder = receiveFolder ?: return
val sink = fileSystemService.createReceiveOutputSink(folder)
val result = if (sink != null) {
repository.receiveTargetedTransferWithOutputSinkV2(transferId, sink)
} else {
repository.receiveTargetedTransfer(transferId, folder.value)
}
result.fold(
onSuccess = {
messages.tryShow(
UiMessage(UiText.Resource(Res.string.receive_completed), UiMessageTone.Success),
)
},
onFailure = messages::error,
)
}
private suspend fun refresh() {
val offers = repository.listPendingTargetedOffers().getOrElse {
messages.error(it)
return
}
val savedDevices = repository.listSavedDevices().getOrElse {
messages.error(it)
return
}
_state.update {
it.copy(
pending = offers.sortedBy(PendingTargetedOfferModel::receivedAt),
senderDisplayNames = savedDevices.associate { device ->
device.endpointId to (
device.localLabel?.takeIf(String::isNotBlank)
?: device.remoteDisplayName?.takeIf(String::isNotBlank)
).orEmpty()
}.filterValues(String::isNotBlank),
)
}
}
}

View File

@@ -1,151 +0,0 @@
package com.vnidrop.app.feature.saveddevices
import com.vnidrop.app.core.DeviceRelationshipModel
import com.vnidrop.app.core.DeviceRelationshipStateModel
import com.vnidrop.app.core.PairingEligibilityModel
import com.vnidrop.app.support.FakeCoreGateway
import com.vnidrop.app.ui.feedback.UiMessageController
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class PairingPromptCoordinatorTest {
@Test
fun waitsForCoreInitializeBeforeRefreshing() = runTest {
// The coordinator must not query domain state before AppViewModel initializes the core.
val core = FakeCoreGateway().apply {
pairingEligibilities = listOf(eligibility("peer-a"))
}
val messages = UiMessageController()
val seen = mutableListOf<String>()
backgroundScope.launch {
messages.messages.collect { seen += it.text.toString() }
}
val coordinator = PairingPromptCoordinator(
core,
messages,
backgroundScope,
)
runCurrent()
advanceUntilIdle()
assertEquals(0, core.listDeviceRelationshipsCount)
assertNull(coordinator.state.value.prompt)
assertTrue(seen.isEmpty())
core.mutableState.value = core.mutableState.value.copy(isInitialized = true)
runCurrent()
advanceUntilIdle()
assertEquals(1, core.listDeviceRelationshipsCount)
assertEquals(PairingPrompt.Eligibility("peer-a", "Remote device"), coordinator.state.value.prompt)
assertTrue(seen.isEmpty())
}
@Test
fun acceptEligibilityRequestsPairing() = runTest {
val core = initializedCore().apply {
pairingEligibilities = listOf(eligibility("peer-a"))
}
val coordinator = PairingPromptCoordinator(
core,
UiMessageController(),
backgroundScope,
)
runCurrent()
advanceUntilIdle()
assertEquals(PairingPrompt.Eligibility("peer-a", "Remote device"), coordinator.state.value.prompt)
coordinator.accept()
runCurrent()
advanceUntilIdle()
assertEquals(listOf("peer-a"), core.requestedPairings)
}
@Test
fun declineEligibilityConsumesWithoutRequest() = runTest {
val core = initializedCore().apply {
pairingEligibilities = listOf(eligibility("peer-a"))
}
val coordinator = PairingPromptCoordinator(
core,
UiMessageController(),
backgroundScope,
)
runCurrent()
advanceUntilIdle()
coordinator.decline()
runCurrent()
advanceUntilIdle()
assertTrue(core.requestedPairings.isEmpty())
assertTrue(core.pairingEligibilities.none { it.peerEndpointId == "peer-a" })
assertNull(coordinator.state.value.prompt)
}
@Test
fun dismissKeepsEligibilityForSavedDevicesArea() = runTest {
val core = initializedCore().apply {
pairingEligibilities = listOf(eligibility("peer-a"))
}
val coordinator = PairingPromptCoordinator(
core,
UiMessageController(),
backgroundScope,
)
runCurrent()
advanceUntilIdle()
coordinator.dismiss()
runCurrent()
advanceUntilIdle()
assertNull(coordinator.state.value.prompt)
assertEquals(1, core.pairingEligibilities.size)
}
@Test
fun incomingPairingRequestAcceptsViaRespond() = runTest {
val core = initializedCore().apply {
deviceRelationships = listOf(incoming("peer-b"))
}
val coordinator = PairingPromptCoordinator(
core,
UiMessageController(),
backgroundScope,
)
runCurrent()
advanceUntilIdle()
assertIs<PairingPrompt.IncomingRequest>(coordinator.state.value.prompt)
coordinator.accept()
runCurrent()
advanceUntilIdle()
assertEquals(listOf("peer-b" to true), core.pairingResponses)
}
private fun initializedCore() = FakeCoreGateway().apply {
mutableState.value = mutableState.value.copy(isInitialized = true)
}
private fun eligibility(peer: String) = PairingEligibilityModel(
peerEndpointId = peer,
remoteDisplayName = "Remote device",
sessionId = "session",
protocolVersion = 1u,
createdAt = 1L,
expiresAt = 2L,
)
private fun incoming(peer: String) = DeviceRelationshipModel(
remoteEndpointId = peer,
state = DeviceRelationshipStateModel.PendingIncoming,
generation = 1u,
minimumProtocolVersion = 1u,
createdAt = 1L,
updatedAt = 1L,
)
}

View File

@@ -1,8 +1,22 @@
package com.vnidrop.app.feature.saveddevices package com.vnidrop.app.feature.saveddevices
import com.vnidrop.app.core.CoreStatus
import com.vnidrop.app.core.DeviceRelationshipModel
import com.vnidrop.app.core.DeviceRelationshipStateModel
import com.vnidrop.app.core.PairingEligibilityModel
import com.vnidrop.app.core.PendingTargetedOfferModel
import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind
import com.vnidrop.app.core.SavedDeviceModel import com.vnidrop.app.core.SavedDeviceModel
import com.vnidrop.app.core.TargetedOfferResponseModel
import com.vnidrop.app.core.TargetedTransferModel
import com.vnidrop.app.core.TargetedTransferStateModel
import com.vnidrop.app.preferences.AppPreferences
import com.vnidrop.app.support.FakeCoreGateway import com.vnidrop.app.support.FakeCoreGateway
import com.vnidrop.app.support.FakeFileSystemService
import com.vnidrop.app.support.FakePreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessageController import com.vnidrop.app.ui.feedback.UiMessageController
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.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
@@ -11,9 +25,11 @@ import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain import kotlinx.coroutines.test.setMain
import uniffi.vnidrop.ReceiveOutputSinkV2
import kotlin.test.AfterTest import kotlin.test.AfterTest
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull import kotlin.test.assertNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
@@ -25,70 +41,281 @@ class SavedDevicesViewModelTest {
} }
@Test @Test
fun loadsSavedDevicesWithoutAnExperimentalPreferenceGate() = runTest { fun initializationLoadsOneSavedDeviceExperienceSnapshot() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler)) Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply { val core = FakeCoreGateway().apply {
mutableState.value = mutableState.value.copy(isInitialized = true) pairingEligibilities = listOf(eligibility("eligible", "Phone"))
savedDevices = listOf(device("peer-always-visible", label = null)) deviceRelationships = listOf(incoming("incoming"))
savedDevices = listOf(device("peer", "Office PC", "Authenticated PC"))
pendingTargetedOffers = listOf(offer("offer", "peer"))
targetedTransfers = listOf(transfer("history", "local", "peer", TargetedTransferStateModel.AwaitingApproval))
} }
val viewModel = SavedDevicesViewModel(core, UiMessageController()) val viewModel = createViewModel(core)
runCurrent()
advanceUntilIdle()
assertEquals(0, core.listSavedDevicesCount)
core.mutableState.value = core.mutableState.value.copy(
isInitialized = true,
status = CoreStatus("local", 0u, 0u),
)
runCurrent() runCurrent()
advanceUntilIdle() advanceUntilIdle()
assertEquals("peer-always-visible", viewModel.state.value.savedDevices.single().endpointId) val state = viewModel.state.value
assertEquals(false, viewModel.state.value.isLoading) assertEquals("peer", state.savedDevices.single().endpointId)
assertEquals(false, viewModel.state.value.loadFailed) assertEquals("offer", state.targetedOffers.current?.transferId)
assertEquals("Office PC", state.targetedOffers.currentSenderDisplayName)
assertEquals(SavedDeviceTransferDirection.Outgoing, state.targetedTransfers.single().direction)
assertEquals("Office PC", state.targetedTransfers.single().peerDisplayName)
assertIs<PairingPrompt.IncomingRequest>(state.pairingPrompt.prompt)
assertEquals(false, state.isLoading)
assertEquals(false, state.loadFailed)
}
@Test
fun pairingPromptCommandsAndDismissalUseTheSameDurableLists() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = initializedCore().apply {
pairingEligibilities = listOf(eligibility("peer-a", "Remote device"))
}
val viewModel = createViewModel(core)
runCurrent()
advanceUntilIdle()
assertEquals(PairingPrompt.Eligibility("peer-a", "Remote device"), viewModel.state.value.pairingPrompt.prompt)
viewModel.dismissPairingPrompt()
runCurrent()
assertNull(viewModel.state.value.pairingPrompt.prompt)
assertEquals("peer-a", viewModel.state.value.eligibilities.single().peerEndpointId)
viewModel.rememberEligible("peer-a")
runCurrent()
advanceUntilIdle()
assertEquals(listOf("peer-a"), core.requestedPairings)
core.deviceRelationships = listOf(incoming("peer-b"))
core.pairingEligibilities = emptyList()
core.mutableSignals.emit(com.vnidrop.app.core.CoreSignal.PairingChanged)
runCurrent()
advanceUntilIdle()
viewModel.acceptPairingPrompt()
runCurrent()
advanceUntilIdle()
assertEquals(listOf("peer-b" to true), core.pairingResponses)
}
@Test
fun acceptingOfferPullsByIdThroughThePlatformSink() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val sink = unusedSink()
val core = initializedCore().apply {
pendingTargetedOffers = listOf(offer("transfer-sink", "sender"))
respondTargetedResult = Result.success(TargetedOfferResponseModel.Approved("transfer-sink"))
}
val viewModel = createViewModel(
core,
FakeFileSystemService(receiveFolder(), receiveOutputSink = sink),
)
runCurrent()
advanceUntilIdle()
viewModel.acceptTargetedOffer("transfer-sink")
runCurrent()
advanceUntilIdle()
assertEquals(listOf("transfer-sink" to true), core.respondedTargetedOffers)
assertEquals(listOf("transfer-sink"), core.receivedTargetedViaSinkIds)
assertTrue(core.receivedTargetedPathDirs.isEmpty())
assertNull(viewModel.state.value.targetedOffers.current)
}
@Test
fun decliningEligibilityAndOfferConsumeOnlyTheirOwnPendingItems() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = initializedCore().apply {
pairingEligibilities = listOf(eligibility("pairing", "Phone"))
pendingTargetedOffers = listOf(offer("offer", "sender"))
respondTargetedResult = Result.success(TargetedOfferResponseModel.Declined)
}
val viewModel = createViewModel(core)
runCurrent()
advanceUntilIdle()
viewModel.declinePairingPrompt()
viewModel.declineTargetedOffer("offer")
runCurrent()
advanceUntilIdle()
assertTrue(core.pairingEligibilities.isEmpty())
assertEquals(listOf("offer" to false), core.respondedTargetedOffers)
assertTrue(core.receivedTargetedTransferIds.isEmpty())
assertNull(viewModel.state.value.targetedOffers.current)
}
@Test
fun interruptedTransferResumeUsesThePlatformSinkWhenAvailable() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = initializedCore().apply {
targetedTransfers = listOf(transfer("resume-sink", "peer", "local", TargetedTransferStateModel.Interrupted))
}
val viewModel = createViewModel(
core,
FakeFileSystemService(receiveFolder(), receiveOutputSink = unusedSink()),
)
runCurrent()
advanceUntilIdle()
viewModel.resumeTargetedTransfer("resume-sink")
runCurrent()
advanceUntilIdle()
assertEquals(listOf("resume-sink"), core.resumedTargetedViaSinkIds)
assertTrue(core.resumedTargetedPathDirs.isEmpty())
}
@Test
fun transferHistoryResumeCancelAndDeleteStayTransferIdScoped() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = initializedCore().apply {
targetedTransfers = listOf(
transfer("resume", "peer", "local", TargetedTransferStateModel.Interrupted),
transfer("cancel", "local", "peer", TargetedTransferStateModel.AwaitingApproval),
transfer("delete", "peer", "local", TargetedTransferStateModel.Completed),
)
}
val viewModel = createViewModel(core)
runCurrent()
advanceUntilIdle()
viewModel.resumeTargetedTransfer("resume")
viewModel.cancelTargetedTransfer("cancel")
viewModel.deleteTargetedTransfer("delete")
runCurrent()
advanceUntilIdle()
assertEquals(listOf("resume"), core.resumedTargetedTransferIds)
assertEquals(listOf("resume" to "/tmp"), core.resumedTargetedPathDirs)
assertEquals(listOf("cancel"), core.cancelledTargetedTransferIds)
assertEquals(listOf("delete"), core.deletedTargetedTransferIds)
assertTrue(viewModel.state.value.targetedTransfers.none { it.id == "delete" })
} }
@Test @Test
fun labelForgetAndBlockUpdateGateway() = runTest { fun labelForgetAndBlockUpdateGateway() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler)) Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply { val core = initializedCore().apply { savedDevices = listOf(device("peer-1", null, null)) }
mutableState.value = mutableState.value.copy(isInitialized = true) val viewModel = createViewModel(core)
savedDevices = listOf(device("peer-1", label = null))
}
val viewModel = SavedDevicesViewModel(
core,
UiMessageController(),
)
runCurrent() runCurrent()
advanceUntilIdle() advanceUntilIdle()
assertEquals(1, viewModel.state.value.savedDevices.size)
viewModel.openLabelEditor("peer-1") viewModel.openLabelEditor("peer-1")
viewModel.setLabelDraft("Kitchen") viewModel.setLabelDraft("Kitchen")
viewModel.saveLabel() viewModel.saveLabel()
runCurrent() runCurrent()
advanceUntilIdle() advanceUntilIdle()
assertEquals(listOf<Pair<String, String?>>("peer-1" to "Kitchen"), core.labeledDevices.toList()) assertEquals(listOf<Pair<String, String?>>("peer-1" to "Kitchen"), core.labeledDevices)
assertEquals("Kitchen", viewModel.state.value.savedDevices.single().localLabel)
viewModel.openLabelEditor("peer-1")
viewModel.clearLabel()
runCurrent()
advanceUntilIdle()
assertEquals(listOf<Pair<String, String?>>("peer-1" to "Kitchen", "peer-1" to null), core.labeledDevices.toList())
assertNull(viewModel.state.value.savedDevices.single().localLabel)
viewModel.forget("peer-1") viewModel.forget("peer-1")
runCurrent() runCurrent()
advanceUntilIdle() advanceUntilIdle()
assertEquals(listOf("peer-1"), core.forgottenDevices.toList()) assertEquals(listOf("peer-1"), core.forgottenDevices)
assertTrue(viewModel.state.value.savedDevices.isEmpty())
core.savedDevices = listOf(device("peer-2", label = "Desk")) core.savedDevices = listOf(device("peer-2", "Desk", null))
viewModel.block("peer-2") viewModel.block("peer-2")
runCurrent() runCurrent()
advanceUntilIdle() advanceUntilIdle()
assertEquals(listOf("peer-2"), core.blockedPeers.toList()) assertEquals(listOf("peer-2"), core.blockedPeers)
} }
private fun device(id: String, label: String?) = SavedDeviceModel( private fun createViewModel(
endpointId = id, core: FakeCoreGateway,
localLabel = label, fileSystemService: FakeFileSystemService = FakeFileSystemService(receiveFolder()),
remoteDisplayName = null, ) = SavedDevicesViewModel(
createdAt = 1L, repository = core,
lastAuthenticatedAt = null, fileSystemService = fileSystemService,
preferencesRepository = FakePreferencesRepository(preferences()),
messages = UiMessageController(),
)
private fun initializedCore() = FakeCoreGateway().apply {
mutableState.value = mutableState.value.copy(
isInitialized = true,
status = CoreStatus("local", 0u, 0u),
) )
} }
private fun eligibility(peer: String, name: String) = PairingEligibilityModel(
peerEndpointId = peer,
remoteDisplayName = name,
sessionId = "session",
protocolVersion = 1u,
createdAt = 1L,
expiresAt = 2L,
)
private fun incoming(peer: String) = DeviceRelationshipModel(
remoteEndpointId = peer,
state = DeviceRelationshipStateModel.PendingIncoming,
generation = 1u,
minimumProtocolVersion = 1u,
createdAt = 1L,
updatedAt = 2L,
)
private fun device(id: String, label: String?, remoteName: String?) = SavedDeviceModel(
endpointId = id,
localLabel = label,
remoteDisplayName = remoteName,
createdAt = 1L,
lastAuthenticatedAt = 2L,
)
private fun offer(id: String, sender: String) = PendingTargetedOfferModel(
transferId = id,
senderEndpointId = sender,
receiverEndpointId = "local",
manifestId = "manifest",
contentHash = "hash",
transferName = "Photos",
fileCount = 1u,
totalSize = 10u,
protocolVersion = 1u,
receivedAt = 1L,
)
private fun transfer(
id: String,
sender: String,
receiver: String,
state: TargetedTransferStateModel,
) = TargetedTransferModel(
id = id,
senderEndpointId = sender,
receiverEndpointId = receiver,
manifestId = "manifest-$id",
transferName = "Transfer $id",
fileCount = 2u,
totalSize = 100u,
verifiedBytes = if (state == TargetedTransferStateModel.Completed) 100u else 40u,
state = state,
createdAt = 1L,
updatedAt = 2L,
)
private fun receiveFolder() = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp")
private fun preferences() = AppPreferences(
username = "User",
receiveFolder = receiveFolder(),
themeMode = ThemeMode.System,
notificationsEnabled = false,
)
private fun unusedSink() = object : ReceiveOutputSinkV2 {
override fun startFile(relativePath: String) = error("unused")
override fun writeChunk(relativePath: String, bytes: ByteArray) = error("unused")
override fun finishFile(relativePath: String) = error("unused")
override fun abortFile(relativePath: String, reason: String) = error("unused")
}
}

View File

@@ -1,183 +0,0 @@
package com.vnidrop.app.feature.saveddevices
import com.vnidrop.app.core.PendingTargetedOfferModel
import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind
import com.vnidrop.app.core.SavedDeviceModel
import com.vnidrop.app.core.TargetedOfferResponseModel
import com.vnidrop.app.preferences.AppPreferences
import com.vnidrop.app.support.FakeCoreGateway
import com.vnidrop.app.support.FakeFileSystemService
import com.vnidrop.app.support.FakePreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessageController
import com.vnidrop.app.ui.theme.ThemeMode
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import uniffi.vnidrop.ReceiveOutputSinkV2
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class TargetedOfferCoordinatorTest {
@Test
fun pendingOfferUsesTheSavedDevicesDisplayNamePolicy() = runTest {
val core = initializedCore().apply {
pendingTargetedOffers = listOf(offer("named-transfer"))
savedDevices = listOf(
SavedDeviceModel(
endpointId = "sender",
localLabel = "Office PC",
remoteDisplayName = "Amira's laptop",
createdAt = 1,
lastAuthenticatedAt = 2,
),
)
}
val coordinator = TargetedOfferCoordinator(
core,
FakeFileSystemService(ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp")),
preferences(),
UiMessageController(),
backgroundScope,
)
runCurrent()
advanceUntilIdle()
assertEquals("Office PC", coordinator.state.value.currentSenderDisplayName)
}
@Test
fun acceptApprovesAndPullsByTransferId() = runTest {
val core = initializedCore().apply {
pendingTargetedOffers = listOf(offer("transfer-1"))
respondTargetedResult = Result.success(TargetedOfferResponseModel.Approved("transfer-1"))
receiveResult = Result.success(Unit)
}
val coordinator = TargetedOfferCoordinator(
core,
FakeFileSystemService(ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp")),
preferences(),
UiMessageController(),
backgroundScope,
)
runCurrent()
advanceUntilIdle()
assertEquals("transfer-1", coordinator.state.value.current?.transferId)
coordinator.accept("transfer-1")
runCurrent()
advanceUntilIdle()
assertEquals(listOf("transfer-1" to true), core.respondedTargetedOffers)
assertEquals(listOf("transfer-1"), core.receivedTargetedTransferIds)
assertEquals(listOf("transfer-1" to "/tmp"), core.receivedTargetedPathDirs)
assertTrue(core.receivedTargetedViaSinkIds.isEmpty())
}
@Test
fun acceptUsesOutputSinkWhenPlatformProvidesOne() = runTest {
val sink = object : ReceiveOutputSinkV2 {
override fun startFile(relativePath: String) = error("unused")
override fun writeChunk(relativePath: String, bytes: ByteArray) = error("unused")
override fun finishFile(relativePath: String) = error("unused")
override fun abortFile(relativePath: String, reason: String) = error("unused")
}
val core = initializedCore().apply {
pendingTargetedOffers = listOf(offer("transfer-sink"))
respondTargetedResult = Result.success(TargetedOfferResponseModel.Approved("transfer-sink"))
receiveResult = Result.success(Unit)
}
val coordinator = TargetedOfferCoordinator(
core,
FakeFileSystemService(
ReceiveFolder(ReceiveFolderKind.AndroidPublicDownloads, "downloads", "Downloads"),
receiveOutputSink = sink,
),
preferences(),
UiMessageController(),
backgroundScope,
)
runCurrent()
advanceUntilIdle()
coordinator.accept("transfer-sink")
runCurrent()
advanceUntilIdle()
assertEquals(listOf("transfer-sink"), core.receivedTargetedViaSinkIds)
assertTrue(core.receivedTargetedPathDirs.isEmpty())
}
@Test
fun declineDoesNotReceive() = runTest {
val core = initializedCore().apply {
pendingTargetedOffers = listOf(offer("transfer-2"))
respondTargetedResult = Result.success(TargetedOfferResponseModel.Declined)
}
val coordinator = TargetedOfferCoordinator(
core,
FakeFileSystemService(ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp")),
preferences(),
UiMessageController(),
backgroundScope,
)
runCurrent()
advanceUntilIdle()
coordinator.decline("transfer-2")
runCurrent()
advanceUntilIdle()
assertEquals(listOf("transfer-2" to false), core.respondedTargetedOffers)
assertTrue(core.receivedTargetedTransferIds.isEmpty())
}
@Test
fun waitsForCoreInitializeBeforeListingOffers() = runTest {
val core = FakeCoreGateway().apply {
pendingTargetedOffers = listOf(offer("transfer-late"))
}
val coordinator = TargetedOfferCoordinator(
core,
FakeFileSystemService(ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp")),
preferences(),
UiMessageController(),
backgroundScope,
)
runCurrent()
advanceUntilIdle()
assertEquals(0, core.listPendingTargetedOffersCount)
assertTrue(coordinator.state.value.pending.isEmpty())
core.mutableState.value = core.mutableState.value.copy(isInitialized = true)
runCurrent()
advanceUntilIdle()
assertEquals(1, core.listPendingTargetedOffersCount)
assertEquals("transfer-late", coordinator.state.value.current?.transferId)
}
private fun initializedCore() = FakeCoreGateway().apply {
mutableState.value = mutableState.value.copy(isInitialized = true)
}
private fun offer(id: String) = PendingTargetedOfferModel(
transferId = id,
senderEndpointId = "sender",
receiverEndpointId = "receiver",
manifestId = "manifest",
contentHash = "hash",
transferName = "Photos",
fileCount = 1u,
totalSize = 10u,
protocolVersion = 1u,
receivedAt = 1L,
)
private fun preferences() = FakePreferencesRepository(
AppPreferences(
username = "User",
receiveFolder = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp"),
themeMode = ThemeMode.System,
notificationsEnabled = false,
),
)
}

View File

@@ -238,6 +238,11 @@ class FakeCoreGateway : CoreGateway {
val receivedTargetedTransferIds = mutableListOf<String>() val receivedTargetedTransferIds = mutableListOf<String>()
val receivedTargetedPathDirs = mutableListOf<Pair<String, String>>() val receivedTargetedPathDirs = mutableListOf<Pair<String, String>>()
val receivedTargetedViaSinkIds = mutableListOf<String>() val receivedTargetedViaSinkIds = mutableListOf<String>()
val resumedTargetedTransferIds = mutableListOf<String>()
val resumedTargetedPathDirs = mutableListOf<Pair<String, String>>()
val resumedTargetedViaSinkIds = mutableListOf<String>()
val cancelledTargetedTransferIds = mutableListOf<String>()
val deletedTargetedTransferIds = mutableListOf<String>()
val respondedTargetedOffers = mutableListOf<Pair<String, Boolean>>() val respondedTargetedOffers = mutableListOf<Pair<String, Boolean>>()
override suspend fun requestSavedDevicePairing(peerEndpointId: String): Result<Boolean> { override suspend fun requestSavedDevicePairing(peerEndpointId: String): Result<Boolean> {
@@ -284,7 +289,9 @@ class FakeCoreGateway : CoreGateway {
} }
override suspend fun respondToTargetedOffer(transferId: String, accepted: Boolean): Result<TargetedOfferResponseModel> { override suspend fun respondToTargetedOffer(transferId: String, accepted: Boolean): Result<TargetedOfferResponseModel> {
respondedTargetedOffers += transferId to accepted respondedTargetedOffers += transferId to accepted
return respondTargetedResult return respondTargetedResult.onSuccess {
pendingTargetedOffers = pendingTargetedOffers.filterNot { offer -> offer.transferId == transferId }
}
} }
override suspend fun createTargetedTransfer( override suspend fun createTargetedTransfer(
receiverEndpointId: String, receiverEndpointId: String,
@@ -318,9 +325,31 @@ class FakeCoreGateway : CoreGateway {
receivedTargetedViaSinkIds += transferId receivedTargetedViaSinkIds += transferId
return receiveResult return receiveResult
} }
override suspend fun resumeTargetedTransfer(id: String, outputDir: String) = receiveResult override suspend fun resumeTargetedTransfer(id: String, outputDir: String): Result<Unit> {
override suspend fun cancelTargetedTransfer(id: String) = Result.success(Unit) resumedTargetedTransferIds += id
override suspend fun deleteTargetedTransfer(id: String) = Result.success(Unit) resumedTargetedPathDirs += id to outputDir
return receiveResult
}
override suspend fun resumeTargetedTransferWithOutputSinkV2(
id: String,
outputSink: ReceiveOutputSinkV2,
): Result<Unit> {
resumedTargetedTransferIds += id
resumedTargetedViaSinkIds += id
return receiveResult
}
override suspend fun cancelTargetedTransfer(id: String): Result<Unit> {
cancelledTargetedTransferIds += id
targetedTransfers = targetedTransfers.map { transfer ->
if (transfer.id == id) transfer.copy(state = com.vnidrop.app.core.TargetedTransferStateModel.Cancelled) else transfer
}
return Result.success(Unit)
}
override suspend fun deleteTargetedTransfer(id: String): Result<Unit> {
deletedTargetedTransferIds += id
targetedTransfers = targetedTransfers.filterNot { it.id == id }
return Result.success(Unit)
}
} }
class FakePreferencesRepository( class FakePreferencesRepository(

View File

@@ -6,9 +6,10 @@ 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.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.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runComposeUiTest import androidx.compose.ui.test.v2.runComposeUiTest
@@ -16,12 +17,15 @@ import com.vnidrop.app.UiPlatform
import com.vnidrop.app.core.DeviceRelationshipModel import com.vnidrop.app.core.DeviceRelationshipModel
import com.vnidrop.app.core.DeviceRelationshipStateModel import com.vnidrop.app.core.DeviceRelationshipStateModel
import com.vnidrop.app.core.PairingEligibilityModel import com.vnidrop.app.core.PairingEligibilityModel
import com.vnidrop.app.core.PendingTargetedOfferModel
import com.vnidrop.app.core.SavedDeviceModel import com.vnidrop.app.core.SavedDeviceModel
import com.vnidrop.app.core.TargetedTransferStateModel
import com.vnidrop.app.ui.platform.LocalUiPlatform import com.vnidrop.app.ui.platform.LocalUiPlatform
import com.vnidrop.app.ui.state.WindowClass import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.theme.VniDropTheme import com.vnidrop.app.ui.theme.VniDropTheme
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.getString
@@ -29,10 +33,15 @@ import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_retry 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_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_forget_action import vnidrop.shared.generated.resources.saved_devices_forget_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_send_action import vnidrop.shared.generated.resources.saved_devices_send_action
import vnidrop.shared.generated.resources.saved_devices_transfer_resume
import vnidrop.shared.generated.resources.saved_devices_transfers_title
@OptIn(ExperimentalTestApi::class) @OptIn(ExperimentalTestApi::class)
class SavedDevicesScreenTest { class SavedDevicesScreenTest {
@@ -53,7 +62,10 @@ class SavedDevicesScreenTest {
runOnIdle { assertEquals(1, retried) } runOnIdle { assertEquals(1, retried) }
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.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)
} }
@Test @Test
@@ -96,9 +108,10 @@ class SavedDevicesScreenTest {
} }
} }
} }
onNodeWithText(Res.string.saved_devices_send_action.value).assertIsNotEnabled() onAllNodesWithContentDescription(Res.string.saved_devices_send_action.value).assertCountEquals(0)
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()
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()
@@ -124,6 +137,106 @@ class SavedDevicesScreenTest {
onNodeWithText(Res.string.saved_devices_forget_action.value).assertIsDisplayed() onNodeWithText(Res.string.saved_devices_forget_action.value).assertIsDisplayed()
} }
@Test
fun directTransferHistoryShowsProgressAndRoutesResumeById() = runComposeUiTest {
val actions = mutableListOf<Pair<String, SavedDeviceTransferAction>>()
setContent {
CompositionLocalProvider(LocalUiPlatform provides UiPlatform.Android) {
VniDropTheme(isDarkTheme = false) {
SavedDevicesScreen(
state = SavedDevicesState(
isLoading = false,
targetedTransfers = listOf(
SavedDeviceTransferItem(
id = "transfer-resume",
peerEndpointId = "peer",
peerDisplayName = "Office PC",
direction = SavedDeviceTransferDirection.Incoming,
transferName = "Project files",
fileCount = 2u,
totalSize = 100u,
verifiedBytes = 40u,
state = TargetedTransferStateModel.Interrupted,
createdAt = 1,
updatedAt = 2,
),
),
),
windowClass = WindowClass.Desktop,
onTransferAction = { id, action -> actions += id to action },
)
}
}
}
onNodeWithText(Res.string.saved_devices_transfers_title.value).assertIsDisplayed()
onNodeWithText("Project files").assertIsDisplayed()
onNodeWithText("Office PC", substring = true).assertIsDisplayed()
onNodeWithText(Res.string.saved_devices_transfer_resume.value).performClick()
runOnIdle {
assertEquals(listOf("transfer-resume" to SavedDeviceTransferAction.Resume), actions)
}
}
@Test
fun desktopHubUsesGroupedDeviceListAndShowsInlineAttentionActions() = runComposeUiTest {
val acceptedOffers = mutableListOf<String>()
setContent {
CompositionLocalProvider(LocalUiPlatform provides UiPlatform.Windows) {
VniDropTheme(isDarkTheme = false) {
SavedDevicesScreen(
state = SavedDevicesState(
isLoading = false,
savedDevices = listOf(
device("peer-one", null, "Amira's phone"),
device("peer-two", "Studio PC", "Workstation"),
),
targetedOffers = TargetedOfferState(
pending = listOf(offer("offer-one", "peer-one", "Holiday photos")),
senderDisplayNames = mapOf("peer-one" to "Amira's phone"),
),
),
windowClass = WindowClass.Desktop,
onAcceptOffer = acceptedOffers::add,
)
}
}
}
onNodeWithText(Res.string.saved_devices_devices_title.value).assertIsDisplayed()
onNodeWithText(Res.string.saved_devices_attention_title.value).assertIsDisplayed()
onNodeWithText("Amira's phone").assertIsDisplayed()
onNodeWithText("Studio PC").assertIsDisplayed()
onNodeWithTag("saved-device-peer-one").assertIsDisplayed()
onNodeWithTag("saved-device-peer-two").assertIsDisplayed()
onNodeWithText("Receive").performClick()
runOnIdle { assertEquals(listOf("offer-one"), acceptedOffers) }
}
@Test
fun compactHubPlacesAttentionBeforeSavedDevices() = runComposeUiTest {
setContent {
CompositionLocalProvider(LocalUiPlatform provides UiPlatform.Android) {
VniDropTheme(isDarkTheme = false) {
SavedDevicesScreen(
state = SavedDevicesState(
isLoading = false,
savedDevices = listOf(device("peer-one", null, "Phone")),
eligibilities = listOf(eligibility("peer-two", "Laptop")),
),
windowClass = WindowClass.Phone,
)
}
}
}
val attentionTop = onNodeWithText(Res.string.saved_devices_attention_title.value)
.fetchSemanticsNode().boundsInRoot.top
val devicesTop = onNodeWithText(Res.string.saved_devices_devices_title.value)
.fetchSemanticsNode().boundsInRoot.top
assertTrue(attentionTop < devicesTop, "compact layouts should surface pending decisions before the device list")
}
private fun device(endpoint: String, label: String?, name: String?) = SavedDeviceModel( private fun device(endpoint: String, label: String?, name: String?) = SavedDeviceModel(
endpointId = endpoint, endpointId = endpoint,
localLabel = label, localLabel = label,
@@ -149,6 +262,19 @@ class SavedDevicesScreenTest {
createdAt = 1, createdAt = 1,
updatedAt = 2, updatedAt = 2,
) )
private fun offer(id: String, sender: String, name: String) = PendingTargetedOfferModel(
transferId = id,
senderEndpointId = sender,
receiverEndpointId = "local",
manifestId = "manifest",
contentHash = "hash",
transferName = name,
fileCount = 2u,
totalSize = 100u,
protocolVersion = 3u,
receivedAt = 1,
)
} }
@Composable @Composable
@@ -157,6 +283,8 @@ private fun SavedDevicesScreen(
windowClass: WindowClass, windowClass: WindowClass,
onRetry: () -> Unit = {}, onRetry: () -> Unit = {},
onBlock: (String) -> Unit = {}, onBlock: (String) -> Unit = {},
onAcceptOffer: (String) -> Unit = {},
onTransferAction: (String, SavedDeviceTransferAction) -> Unit = { _, _ -> },
) = SavedDevicesScreen( ) = SavedDevicesScreen(
state = state, state = state,
windowClass = windowClass, windowClass = windowClass,
@@ -165,10 +293,13 @@ private fun SavedDevicesScreen(
onDeclineEligible = {}, onDeclineEligible = {},
onAcceptIncoming = {}, onAcceptIncoming = {},
onDeclineIncoming = {}, onDeclineIncoming = {},
onAcceptOffer = onAcceptOffer,
onDeclineOffer = {},
onSend = {}, onSend = {},
onOpenLabel = {}, onOpenLabel = {},
onForget = {}, onForget = {},
onBlock = onBlock, onBlock = onBlock,
onTransferAction = onTransferAction,
onLabelDraftChanged = {}, onLabelDraftChanged = {},
onSaveLabel = {}, onSaveLabel = {},
onClearLabel = {}, onClearLabel = {},