mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-13 05:49:57 +02:00
feat(shared): graduate saved devices experience
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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
|
||||
@@ -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.
|
||||
5. Model state and platform behavior before drawing pixels.
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
- 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
|
||||
|
||||
- `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.
|
||||
- Prefer deterministic gates and virtual time; avoid fixed sleeps.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
interface:
|
||||
display_name: "VniDrop KMP UI"
|
||||
short_description: "Build native-feeling VniDrop KMP interfaces"
|
||||
default_prompt: "Use $compose-skill to design or implement a native-feeling VniDrop KMP UI feature."
|
||||
short_description: "Build and visually verify native VniDrop UI"
|
||||
default_prompt: "Use $compose-skill to design, implement, launch, and visually verify a native-feeling VniDrop KMP UI feature."
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -21,6 +21,7 @@ target/
|
||||
.junie
|
||||
config.override.mk
|
||||
bin/
|
||||
tmp/
|
||||
|
||||
# Local design export scratch
|
||||
output/
|
||||
|
||||
@@ -4828,6 +4828,185 @@
|
||||
"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 d’approbation", "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": {
|
||||
"context": "Saved-device card: authenticated name shared by the remote app when a local label is also shown.",
|
||||
"translations": {
|
||||
@@ -4914,22 +5093,37 @@
|
||||
]
|
||||
},
|
||||
"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": {
|
||||
"en": "No saved devices yet. Finish a transfer, then remember a device.",
|
||||
"fr": "No saved devices yet. Finish a transfer, then remember a device.",
|
||||
"es": "No saved devices yet. Finish a transfer, then remember a device.",
|
||||
"it": "No saved devices yet. Finish a transfer, then remember a device.",
|
||||
"de": "No saved devices yet. Finish a transfer, then remember a device.",
|
||||
"pt": "No saved devices yet. Finish a transfer, then remember a device.",
|
||||
"pl": "No saved devices yet. Finish a transfer, then remember a device.",
|
||||
"nl": "No saved devices yet. Finish a transfer, then remember a device.",
|
||||
"ru": "No saved devices yet. Finish a transfer, then remember a device."
|
||||
"en": "Finish a transfer, then choose to remember the other device.",
|
||||
"fr": "Terminez un transfert, puis choisissez de mémoriser l’autre appareil.",
|
||||
"es": "Finaliza una transferencia y elige recordar el otro dispositivo.",
|
||||
"it": "Completa un trasferimento, quindi scegli di ricordare l’altro dispositivo.",
|
||||
"de": "Schließe eine Übertragung ab und speichere anschließend das andere Gerät.",
|
||||
"pt": "Conclua uma transferência e escolha guardar o outro dispositivo.",
|
||||
"pl": "Ukończ transfer, a następnie zapisz drugie urządzenie.",
|
||||
"nl": "Voltooi een overdracht en kies daarna om het andere apparaat te onthouden.",
|
||||
"ru": "Завершите передачу, затем сохраните другое устройство."
|
||||
},
|
||||
"targets": [
|
||||
"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": {
|
||||
"context": "Confirmation message before forgetting a saved device. Placeholder is the display name.",
|
||||
"translations": {
|
||||
|
||||
@@ -24,6 +24,8 @@ lists, animation, accessibility:
|
||||
3. Open **at most one** file under `.codex/skills/compose-skill/references/` when
|
||||
the skill links to it for the current task.
|
||||
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
|
||||
|
||||
|
||||
@@ -319,12 +319,32 @@
|
||||
<string name="offer_body">%1$s möchte Ihnen „%2$s“ senden.</string>
|
||||
<string name="offer_accept">Empfangen</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_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_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_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_title">Gerät vergessen?</string>
|
||||
<string name="saved_devices_eligibility_title">Ready to remember</string>
|
||||
|
||||
@@ -319,12 +319,32 @@
|
||||
<string name="offer_body">%1$s quiere enviarte «%2$s».</string>
|
||||
<string name="offer_accept">Recibir</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_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_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_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_title">¿Olvidar dispositivo?</string>
|
||||
<string name="saved_devices_eligibility_title">Ready to remember</string>
|
||||
|
||||
@@ -319,12 +319,32 @@
|
||||
<string name="offer_body">%1$s souhaite vous envoyer « %2$s ».</string>
|
||||
<string name="offer_accept">Recevoir</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 d’approbation</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_block_confirm_body">Bloquer %1$s et refuser les futurs transferts d’appareil enregistré ?</string>
|
||||
<string name="saved_devices_block_confirm_title">Bloquer l’appareil ?</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 l’appareil : %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 l’autre 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 l’enregistrement avant un autre transfert direct.</string>
|
||||
<string name="saved_devices_forget_confirm_title">Oublier l’appareil ?</string>
|
||||
<string name="saved_devices_eligibility_title">Ready to remember</string>
|
||||
|
||||
@@ -319,12 +319,32 @@
|
||||
<string name="offer_body">%1$s vuole inviarti «%2$s».</string>
|
||||
<string name="offer_accept">Ricevi</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_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_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_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 l’altro 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_title">Dimenticare il dispositivo?</string>
|
||||
<string name="saved_devices_eligibility_title">Ready to remember</string>
|
||||
|
||||
@@ -319,12 +319,32 @@
|
||||
<string name="offer_body">%1$s wil je “%2$s” sturen.</string>
|
||||
<string name="offer_accept">Ontvangen</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_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_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_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_title">Apparaat vergeten?</string>
|
||||
<string name="saved_devices_eligibility_title">Ready to remember</string>
|
||||
|
||||
@@ -319,12 +319,32 @@
|
||||
<string name="offer_body">%1$s chce wysłać Ci „%2$s”.</string>
|
||||
<string name="offer_accept">Odbierz</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_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_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_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_title">Zapomnieć urządzenie?</string>
|
||||
<string name="saved_devices_eligibility_title">Ready to remember</string>
|
||||
|
||||
@@ -319,12 +319,32 @@
|
||||
<string name="offer_body">%1$s quer enviar-lhe “%2$s”.</string>
|
||||
<string name="offer_accept">Receber</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_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_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_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_title">Esquecer dispositivo?</string>
|
||||
<string name="saved_devices_eligibility_title">Ready to remember</string>
|
||||
|
||||
@@ -319,12 +319,32 @@
|
||||
<string name="offer_body">%1$s хочет отправить вам «%2$s».</string>
|
||||
<string name="offer_accept">Получить</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_block_confirm_body">Заблокировать %1$s и отклонять будущие передачи с сохранённых устройств?</string>
|
||||
<string name="saved_devices_block_confirm_title">Заблокировать устройство?</string>
|
||||
<string name="saved_devices_description">Отправляйте напрямую доверенным устройствам без новой ссылки-приглашения.</string>
|
||||
<string name="saved_devices_endpoint">ID устройства: %1$s</string>
|
||||
<string name="saved_devices_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_title">Забыть устройство?</string>
|
||||
<string name="saved_devices_eligibility_title">Ready to remember</string>
|
||||
|
||||
@@ -319,12 +319,32 @@
|
||||
<string name="offer_body">%1$s wants to send you “%2$s”.</string>
|
||||
<string name="offer_accept">Receive</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_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_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_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_title">Forget device?</string>
|
||||
<string name="saved_devices_eligibility_title">Ready to remember</string>
|
||||
|
||||
@@ -132,6 +132,8 @@ fun App(
|
||||
val savedDevicesViewModel = viewModel {
|
||||
SavedDevicesViewModel(
|
||||
graph.coreRepository,
|
||||
dependencies.fileSystemService,
|
||||
graph.preferencesRepository,
|
||||
graph.messages,
|
||||
)
|
||||
}
|
||||
@@ -141,8 +143,7 @@ fun App(
|
||||
val receiveState by receiveViewModel.state.collectAsStateWithLifecycle()
|
||||
val receiveCoreState by receiveViewModel.coreState.collectAsStateWithLifecycle()
|
||||
val approvalState by graph.approvalCoordinator.state.collectAsStateWithLifecycle()
|
||||
val pairingPromptState by graph.pairingPromptCoordinator.state.collectAsStateWithLifecycle()
|
||||
val targetedOfferState by graph.targetedOfferCoordinator.state.collectAsStateWithLifecycle()
|
||||
val savedDevicesState by savedDevicesViewModel.state.collectAsStateWithLifecycle()
|
||||
val username by graph.preferencesRepository.preferences
|
||||
.map { it.username }
|
||||
.collectAsStateWithLifecycle(initialValue = dependencies.environment.defaultUsername)
|
||||
@@ -266,17 +267,19 @@ fun App(
|
||||
onAccept = graph.approvalCoordinator::accept,
|
||||
onRefuse = graph.approvalCoordinator::refuse,
|
||||
)
|
||||
PairingPromptHost(
|
||||
state = pairingPromptState,
|
||||
onAccept = graph.pairingPromptCoordinator::accept,
|
||||
onDecline = graph.pairingPromptCoordinator::decline,
|
||||
onDismiss = graph.pairingPromptCoordinator::dismiss,
|
||||
)
|
||||
TargetedOfferModalHost(
|
||||
state = targetedOfferState,
|
||||
onAccept = graph.targetedOfferCoordinator::accept,
|
||||
onDecline = graph.targetedOfferCoordinator::decline,
|
||||
)
|
||||
if (appState.destination != AppDestination.SavedDevices) {
|
||||
PairingPromptHost(
|
||||
state = savedDevicesState.pairingPrompt,
|
||||
onAccept = savedDevicesViewModel::acceptPairingPrompt,
|
||||
onDecline = savedDevicesViewModel::declinePairingPrompt,
|
||||
onDismiss = savedDevicesViewModel::dismissPairingPrompt,
|
||||
)
|
||||
TargetedOfferModalHost(
|
||||
state = savedDevicesState.targetedOffers,
|
||||
onAccept = savedDevicesViewModel::acceptTargetedOffer,
|
||||
onDecline = savedDevicesViewModel::declineTargetedOffer,
|
||||
)
|
||||
}
|
||||
TransferDraftHost(targetedDraftViewModel, windowClass, onCreated = {})
|
||||
}
|
||||
windowChrome?.invoke()
|
||||
|
||||
@@ -5,8 +5,6 @@ import com.vnidrop.app.core.CoreRepository
|
||||
import com.vnidrop.app.diagnostics.DiagnosticsCoordinator
|
||||
import com.vnidrop.app.diagnostics.createDiagnosticsTransport
|
||||
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.createPlatformPreviewStore
|
||||
import com.vnidrop.app.logging.AppLogger
|
||||
@@ -60,18 +58,6 @@ class AppGraph(
|
||||
messages = messages,
|
||||
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(
|
||||
repository = coreRepository,
|
||||
preferencesRepository = preferencesRepository,
|
||||
|
||||
@@ -228,6 +228,7 @@ interface CoreGateway {
|
||||
outputSink: ReceiveOutputSinkV2,
|
||||
): 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 deleteTargetedTransfer(id: String): Result<Unit>
|
||||
}
|
||||
|
||||
@@ -358,6 +358,13 @@ class CoreRepository internal constructor(
|
||||
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 ->
|
||||
activeCore.cancelTargetedTransfer(id)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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) + "…"
|
||||
@@ -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) + "…"
|
||||
@@ -25,6 +25,8 @@ internal fun SavedDevicesRoute(
|
||||
onDeclineEligible = viewModel::declineEligible,
|
||||
onAcceptIncoming = viewModel::acceptIncoming,
|
||||
onDeclineIncoming = viewModel::declineIncoming,
|
||||
onAcceptOffer = viewModel::acceptTargetedOffer,
|
||||
onDeclineOffer = viewModel::declineTargetedOffer,
|
||||
onSend = { peerEndpointId ->
|
||||
state.savedDevices.firstOrNull { it.endpointId == peerEndpointId }
|
||||
?.let { targetedDraftViewModel.openTargeted(it, unnamedDeviceName) }
|
||||
@@ -32,6 +34,14 @@ internal fun SavedDevicesRoute(
|
||||
onOpenLabel = viewModel::openLabelEditor,
|
||||
onForget = viewModel::forget,
|
||||
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,
|
||||
onSaveLabel = viewModel::saveLabel,
|
||||
onClearLabel = viewModel::clearLabel,
|
||||
|
||||
@@ -1,52 +1,28 @@
|
||||
package com.vnidrop.app.feature.saveddevices
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.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.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
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.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.heading
|
||||
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.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.icons.AppIcon
|
||||
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.button_cancel
|
||||
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_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_placeholder
|
||||
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_load_failed
|
||||
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
|
||||
internal fun SavedDevicesScreen(
|
||||
@@ -96,50 +51,58 @@ internal fun SavedDevicesScreen(
|
||||
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,
|
||||
onLabelDraftChanged: (String) -> Unit,
|
||||
onSaveLabel: () -> Unit,
|
||||
onClearLabel: () -> 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(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.statusBarsPadding()
|
||||
.padding(top = 20.dp),
|
||||
modifier = modifier.fillMaxSize().statusBarsPadding().padding(top = 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 {
|
||||
state.isLoading && !hasContent -> SavedDevicesLoading(Modifier.weight(1f))
|
||||
state.loadFailed && !hasContent -> SavedDevicesLoadFailure(onRetry, Modifier.weight(1f))
|
||||
windowClass == WindowClass.Desktop -> DesktopSavedDevicesContent(
|
||||
windowClass == WindowClass.Desktop -> DesktopSavedDevicesHub(
|
||||
state = state,
|
||||
onRetry = onRetry,
|
||||
onRememberEligible = onRememberEligible,
|
||||
onDeclineEligible = onDeclineEligible,
|
||||
onAcceptIncoming = onAcceptIncoming,
|
||||
onDeclineIncoming = onDeclineIncoming,
|
||||
onAcceptOffer = onAcceptOffer,
|
||||
onDeclineOffer = onDeclineOffer,
|
||||
onSend = onSend,
|
||||
onOpenLabel = onOpenLabel,
|
||||
onForget = onForget,
|
||||
onBlock = onBlock,
|
||||
onTransferAction = onTransferAction,
|
||||
)
|
||||
else -> CompactSavedDevicesContent(
|
||||
else -> CompactSavedDevicesHub(
|
||||
state = state,
|
||||
onRetry = onRetry,
|
||||
onRememberEligible = onRememberEligible,
|
||||
onDeclineEligible = onDeclineEligible,
|
||||
onAcceptIncoming = onAcceptIncoming,
|
||||
onDeclineIncoming = onDeclineIncoming,
|
||||
onAcceptOffer = onAcceptOffer,
|
||||
onDeclineOffer = onDeclineOffer,
|
||||
onSend = onSend,
|
||||
onOpenLabel = onOpenLabel,
|
||||
onForget = onForget,
|
||||
onBlock = onBlock,
|
||||
onTransferAction = onTransferAction,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -155,436 +118,21 @@ internal fun SavedDevicesScreen(
|
||||
|
||||
@Composable
|
||||
private fun SavedDevicesHeader(modifier: Modifier = Modifier) {
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = stringResource(Res.string.saved_devices_list_title),
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.semantics { heading() },
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.saved_devices_description),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
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
|
||||
private fun SavedDevicesLoading(modifier: Modifier = Modifier) {
|
||||
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
|
||||
private fun SavedDeviceLabelDialog(
|
||||
visible: Boolean,
|
||||
@@ -649,18 +180,10 @@ private fun SavedDeviceLabelDialog(
|
||||
TextButton(onClick = onSave) { Text(stringResource(Res.string.saved_devices_label_save)) }
|
||||
},
|
||||
dismissButton = {
|
||||
Row {
|
||||
androidx.compose.foundation.layout.Row {
|
||||
TextButton(onClick = onClear) { Text(stringResource(Res.string.saved_devices_label_clear)) }
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(Res.string.button_cancel)) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@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) + "…"
|
||||
|
||||
@@ -6,8 +6,14 @@ import com.vnidrop.app.core.CoreGateway
|
||||
import com.vnidrop.app.core.CoreSignal
|
||||
import com.vnidrop.app.core.DeviceRelationshipModel
|
||||
import com.vnidrop.app.core.DeviceRelationshipStateModel
|
||||
import com.vnidrop.app.core.FileSystemService
|
||||
import com.vnidrop.app.core.PairingEligibilityModel
|
||||
import com.vnidrop.app.core.ReceiveFolder
|
||||
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.UiMessageController
|
||||
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.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 kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
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_forgotten
|
||||
import vnidrop.shared.generated.resources.saved_devices_labeled
|
||||
@@ -30,23 +41,41 @@ data class SavedDevicesState(
|
||||
val eligibilities: List<PairingEligibilityModel> = emptyList(),
|
||||
val pendingRelationships: List<DeviceRelationshipModel> = 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 busyTransferIds: Set<String> = emptySet(),
|
||||
val labelingPeerId: String? = null,
|
||||
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(
|
||||
private val repository: CoreGateway,
|
||||
private val fileSystemService: FileSystemService,
|
||||
private val preferencesRepository: PreferencesRepository,
|
||||
private val messages: UiMessageController,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(SavedDevicesState())
|
||||
val state: StateFlow<SavedDevicesState> = _state.asStateFlow()
|
||||
|
||||
private val refreshMutex = Mutex()
|
||||
private val dismissedEligibility = mutableSetOf<String>()
|
||||
private var receiveFolder: ReceiveFolder? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
repository.state.map { it.isInitialized }
|
||||
combine(
|
||||
preferencesRepository.preferences,
|
||||
repository.state.map { it.isInitialized },
|
||||
) { preferences, initialized -> preferences.receiveFolder to initialized }
|
||||
.distinctUntilChanged()
|
||||
.collect { initialized ->
|
||||
.collectLatest { (configuredFolder, initialized) ->
|
||||
receiveFolder = fileSystemService.effectiveReceiveFolder(configuredFolder)
|
||||
if (initialized) refresh()
|
||||
}
|
||||
}
|
||||
@@ -58,8 +87,8 @@ class SavedDevicesViewModel(
|
||||
if (repository.state.value.isInitialized) refresh()
|
||||
}
|
||||
is CoreSignal.ApprovalChanged,
|
||||
is CoreSignal.ReceiverHistoryChanged,
|
||||
is CoreSignal.TransfersChanged -> Unit
|
||||
is CoreSignal.ReceiverHistoryChanged,
|
||||
is CoreSignal.TransfersChanged -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,6 +99,44 @@ class SavedDevicesViewModel(
|
||||
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) {
|
||||
repository.requestSavedDevicePairing(peerEndpointId).map { }
|
||||
}
|
||||
@@ -86,6 +153,18 @@ class SavedDevicesViewModel(
|
||||
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) {
|
||||
val current = _state.value.savedDevices.firstOrNull { it.endpointId == peerEndpointId }
|
||||
_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<*>) {
|
||||
if (peerEndpointId in _state.value.busyPeerIds) return
|
||||
_state.update { it.copy(busyPeerIds = it.busyPeerIds + peerEndpointId) }
|
||||
viewModelScope.launch {
|
||||
block().fold(
|
||||
onSuccess = { refresh() },
|
||||
onFailure = messages::error,
|
||||
)
|
||||
block().fold(onSuccess = { refresh() }, onFailure = messages::error)
|
||||
_state.update { it.copy(busyPeerIds = it.busyPeerIds - peerEndpointId) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refresh() {
|
||||
_state.update { it.copy(isLoading = true, loadFailed = false) }
|
||||
val eligibilities = repository.listPairingEligibilities().getOrElse {
|
||||
_state.update { state -> state.copy(isLoading = false, loadFailed = true) }
|
||||
messages.error(it)
|
||||
return
|
||||
}
|
||||
val relationships = repository.listDeviceRelationships().getOrElse {
|
||||
_state.update { state -> state.copy(isLoading = false, loadFailed = true) }
|
||||
messages.error(it)
|
||||
return
|
||||
}
|
||||
val saved = repository.listSavedDevices().getOrElse {
|
||||
_state.update { state -> state.copy(isLoading = false, loadFailed = true) }
|
||||
messages.error(it)
|
||||
return
|
||||
}
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
loadFailed = false,
|
||||
eligibilities = eligibilities.sortedByDescending(PairingEligibilityModel::createdAt),
|
||||
pendingRelationships = relationships.filter {
|
||||
it.state == DeviceRelationshipStateModel.PendingIncoming ||
|
||||
it.state == DeviceRelationshipStateModel.PendingOutgoing
|
||||
}.sortedByDescending(DeviceRelationshipModel::updatedAt),
|
||||
savedDevices = saved.sortedByDescending(SavedDeviceModel::createdAt),
|
||||
)
|
||||
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() {
|
||||
refreshMutex.withLock {
|
||||
_state.update { it.copy(isLoading = true, loadFailed = false) }
|
||||
val eligibilities = repository.listPairingEligibilities().getOrElse {
|
||||
refreshFailed(it)
|
||||
return@withLock
|
||||
}
|
||||
val relationships = repository.listDeviceRelationships().getOrElse {
|
||||
refreshFailed(it)
|
||||
return@withLock
|
||||
}
|
||||
val savedDevices = repository.listSavedDevices().getOrElse {
|
||||
refreshFailed(it)
|
||||
return@withLock
|
||||
}
|
||||
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 {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
loadFailed = false,
|
||||
eligibilities = eligibilities.sortedByDescending(PairingEligibilityModel::createdAt),
|
||||
pendingRelationships = pendingRelationships,
|
||||
savedDevices = savedDevices.sortedByDescending(SavedDeviceModel::createdAt),
|
||||
targetedTransfers = targetedTransfers
|
||||
.filterNot { transfer -> transfer.state == TargetedTransferStateModel.Deleted }
|
||||
.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)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,22 @@
|
||||
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.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.FakeFileSystemService
|
||||
import com.vnidrop.app.support.FakePreferencesRepository
|
||||
import com.vnidrop.app.ui.feedback.UiMessageController
|
||||
import com.vnidrop.app.ui.theme.ThemeMode
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
@@ -11,9 +25,11 @@ import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import uniffi.vnidrop.ReceiveOutputSinkV2
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@@ -25,70 +41,281 @@ class SavedDevicesViewModelTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadsSavedDevicesWithoutAnExperimentalPreferenceGate() = runTest {
|
||||
fun initializationLoadsOneSavedDeviceExperienceSnapshot() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val core = FakeCoreGateway().apply {
|
||||
mutableState.value = mutableState.value.copy(isInitialized = true)
|
||||
savedDevices = listOf(device("peer-always-visible", label = null))
|
||||
pairingEligibilities = listOf(eligibility("eligible", "Phone"))
|
||||
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()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("peer-always-visible", viewModel.state.value.savedDevices.single().endpointId)
|
||||
assertEquals(false, viewModel.state.value.isLoading)
|
||||
assertEquals(false, viewModel.state.value.loadFailed)
|
||||
val state = viewModel.state.value
|
||||
assertEquals("peer", state.savedDevices.single().endpointId)
|
||||
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
|
||||
fun labelForgetAndBlockUpdateGateway() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val core = FakeCoreGateway().apply {
|
||||
mutableState.value = mutableState.value.copy(isInitialized = true)
|
||||
savedDevices = listOf(device("peer-1", label = null))
|
||||
}
|
||||
val viewModel = SavedDevicesViewModel(
|
||||
core,
|
||||
UiMessageController(),
|
||||
)
|
||||
val core = initializedCore().apply { savedDevices = listOf(device("peer-1", null, null)) }
|
||||
val viewModel = createViewModel(core)
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
assertEquals(1, viewModel.state.value.savedDevices.size)
|
||||
|
||||
viewModel.openLabelEditor("peer-1")
|
||||
viewModel.setLabelDraft("Kitchen")
|
||||
viewModel.saveLabel()
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
assertEquals(listOf<Pair<String, String?>>("peer-1" to "Kitchen"), core.labeledDevices.toList())
|
||||
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)
|
||||
assertEquals(listOf<Pair<String, String?>>("peer-1" to "Kitchen"), core.labeledDevices)
|
||||
|
||||
viewModel.forget("peer-1")
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
assertEquals(listOf("peer-1"), core.forgottenDevices.toList())
|
||||
assertTrue(viewModel.state.value.savedDevices.isEmpty())
|
||||
assertEquals(listOf("peer-1"), core.forgottenDevices)
|
||||
|
||||
core.savedDevices = listOf(device("peer-2", label = "Desk"))
|
||||
core.savedDevices = listOf(device("peer-2", "Desk", null))
|
||||
viewModel.block("peer-2")
|
||||
runCurrent()
|
||||
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(
|
||||
core: FakeCoreGateway,
|
||||
fileSystemService: FakeFileSystemService = FakeFileSystemService(receiveFolder()),
|
||||
) = SavedDevicesViewModel(
|
||||
repository = core,
|
||||
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 = null,
|
||||
remoteDisplayName = remoteName,
|
||||
createdAt = 1L,
|
||||
lastAuthenticatedAt = null,
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -238,6 +238,11 @@ class FakeCoreGateway : CoreGateway {
|
||||
val receivedTargetedTransferIds = mutableListOf<String>()
|
||||
val receivedTargetedPathDirs = mutableListOf<Pair<String, 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>>()
|
||||
|
||||
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> {
|
||||
respondedTargetedOffers += transferId to accepted
|
||||
return respondTargetedResult
|
||||
return respondTargetedResult.onSuccess {
|
||||
pendingTargetedOffers = pendingTargetedOffers.filterNot { offer -> offer.transferId == transferId }
|
||||
}
|
||||
}
|
||||
override suspend fun createTargetedTransfer(
|
||||
receiverEndpointId: String,
|
||||
@@ -318,9 +325,31 @@ class FakeCoreGateway : CoreGateway {
|
||||
receivedTargetedViaSinkIds += transferId
|
||||
return receiveResult
|
||||
}
|
||||
override suspend fun resumeTargetedTransfer(id: String, outputDir: String) = receiveResult
|
||||
override suspend fun cancelTargetedTransfer(id: String) = Result.success(Unit)
|
||||
override suspend fun deleteTargetedTransfer(id: String) = Result.success(Unit)
|
||||
override suspend fun resumeTargetedTransfer(id: String, outputDir: String): Result<Unit> {
|
||||
resumedTargetedTransferIds += id
|
||||
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(
|
||||
|
||||
@@ -6,9 +6,10 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
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.DeviceRelationshipStateModel
|
||||
import com.vnidrop.app.core.PairingEligibilityModel
|
||||
import com.vnidrop.app.core.PendingTargetedOfferModel
|
||||
import com.vnidrop.app.core.SavedDeviceModel
|
||||
import com.vnidrop.app.core.TargetedTransferStateModel
|
||||
import com.vnidrop.app.ui.platform.LocalUiPlatform
|
||||
import com.vnidrop.app.ui.state.WindowClass
|
||||
import com.vnidrop.app.ui.theme.VniDropTheme
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
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.saved_devices_block_action
|
||||
import vnidrop.shared.generated.resources.saved_devices_block_confirm_title
|
||||
import vnidrop.shared.generated.resources.saved_devices_attention_title
|
||||
import vnidrop.shared.generated.resources.saved_devices_devices_title
|
||||
import vnidrop.shared.generated.resources.saved_devices_empty
|
||||
import vnidrop.shared.generated.resources.saved_devices_empty_title
|
||||
import vnidrop.shared.generated.resources.saved_devices_forget_action
|
||||
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_transfer_resume
|
||||
import vnidrop.shared.generated.resources.saved_devices_transfers_title
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class SavedDevicesScreenTest {
|
||||
@@ -53,7 +62,10 @@ class SavedDevicesScreenTest {
|
||||
runOnIdle { assertEquals(1, retried) }
|
||||
|
||||
runOnIdle { state.value = SavedDevicesState(isLoading = false) }
|
||||
onNodeWithText(Res.string.saved_devices_empty_title.value).assertIsDisplayed()
|
||||
onNodeWithText(Res.string.saved_devices_empty.value).assertIsDisplayed()
|
||||
onAllNodesWithText(Res.string.saved_devices_devices_title.value).assertCountEquals(0)
|
||||
onAllNodesWithText(Res.string.saved_devices_transfers_title.value).assertCountEquals(0)
|
||||
}
|
||||
|
||||
@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()) }
|
||||
onNodeWithContentDescription(Res.string.saved_devices_send_action.value).assertIsDisplayed()
|
||||
onNodeWithContentDescription("More actions for Riley's phone").performClick()
|
||||
onNodeWithText(Res.string.saved_devices_block_action.value).performClick()
|
||||
onNodeWithText(Res.string.saved_devices_block_confirm_title.value).assertIsDisplayed()
|
||||
@@ -124,6 +137,106 @@ class SavedDevicesScreenTest {
|
||||
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(
|
||||
endpointId = endpoint,
|
||||
localLabel = label,
|
||||
@@ -149,6 +262,19 @@ class SavedDevicesScreenTest {
|
||||
createdAt = 1,
|
||||
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
|
||||
@@ -157,6 +283,8 @@ private fun SavedDevicesScreen(
|
||||
windowClass: WindowClass,
|
||||
onRetry: () -> Unit = {},
|
||||
onBlock: (String) -> Unit = {},
|
||||
onAcceptOffer: (String) -> Unit = {},
|
||||
onTransferAction: (String, SavedDeviceTransferAction) -> Unit = { _, _ -> },
|
||||
) = SavedDevicesScreen(
|
||||
state = state,
|
||||
windowClass = windowClass,
|
||||
@@ -165,10 +293,13 @@ private fun SavedDevicesScreen(
|
||||
onDeclineEligible = {},
|
||||
onAcceptIncoming = {},
|
||||
onDeclineIncoming = {},
|
||||
onAcceptOffer = onAcceptOffer,
|
||||
onDeclineOffer = {},
|
||||
onSend = {},
|
||||
onOpenLabel = {},
|
||||
onForget = {},
|
||||
onBlock = onBlock,
|
||||
onTransferAction = onTransferAction,
|
||||
onLabelDraftChanged = {},
|
||||
onSaveLabel = {},
|
||||
onClearLabel = {},
|
||||
|
||||
Reference in New Issue
Block a user