diff --git a/.codex/skills/compose-skill/SKILL.md b/.codex/skills/compose-skill/SKILL.md index 999f479..4931296 100644 --- a/.codex/skills/compose-skill/SKILL.md +++ b/.codex/skills/compose-skill/SKILL.md @@ -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 diff --git a/.codex/skills/compose-skill/agents/openai.yaml b/.codex/skills/compose-skill/agents/openai.yaml index 0bfedfe..c50d4df 100644 --- a/.codex/skills/compose-skill/agents/openai.yaml +++ b/.codex/skills/compose-skill/agents/openai.yaml @@ -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." diff --git a/.gitignore b/.gitignore index 2ae32fd..99f461b 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ target/ .junie config.override.mk bin/ +tmp/ # Local design export scratch output/ diff --git a/localization/strings.json b/localization/strings.json index a57b46f..f9958d5 100644 --- a/localization/strings.json +++ b/localization/strings.json @@ -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": { diff --git a/shared/AGENTS.md b/shared/AGENTS.md index 99d1c4a..b8c68a8 100644 --- a/shared/AGENTS.md +++ b/shared/AGENTS.md @@ -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 diff --git a/shared/src/commonMain/composeResources/values-de/strings.xml b/shared/src/commonMain/composeResources/values-de/strings.xml index c5afabd..ff4a2d4 100644 --- a/shared/src/commonMain/composeResources/values-de/strings.xml +++ b/shared/src/commonMain/composeResources/values-de/strings.xml @@ -319,12 +319,32 @@ %1$s möchte Ihnen „%2$s“ senden. Empfangen Ablehnen + Erfordert Aufmerksamkeit + Deine Geräte + Direkte Übertragungen + Noch keine direkten Übertragungen. + Von %%1$s + An %%1$s + %%1$s Dateien · %%2$s + %%1$s von %%2$s + Empfangen + Fortsetzen + Abbrechen + Löschen + Wird angeboten + Wartet auf Genehmigung + Genehmigt + Verbindung wird hergestellt + Unterbrochen + Übertragung läuft + Abgelehnt Remote-Name: %1$s %1$s blockieren und künftige Übertragungen gespeicherter Geräte ablehnen? Gerät blockieren? Direkt an vertrauenswürdige Geräte senden, ohne eine neue Einladung zu teilen. Geräte-ID: %1$s - No saved devices yet. Finish a transfer, then remember a device. + Schließe eine Übertragung ab und speichere anschließend das andere Gerät. + Keine gespeicherten Geräte %1$s vergessen? Vor einer weiteren direkten Übertragung müssen beide das Speichern erneut bestätigen. Gerät vergessen? Ready to remember diff --git a/shared/src/commonMain/composeResources/values-es/strings.xml b/shared/src/commonMain/composeResources/values-es/strings.xml index 8aee7ed..da2f1ac 100644 --- a/shared/src/commonMain/composeResources/values-es/strings.xml +++ b/shared/src/commonMain/composeResources/values-es/strings.xml @@ -319,12 +319,32 @@ %1$s quiere enviarte «%2$s». Recibir Rechazar + Requiere atención + Tus dispositivos + Transferencias directas + Aún no hay transferencias directas. + De %%1$s + A %%1$s + %%1$s archivos · %%2$s + %%1$s de %%2$s + Recibir + Reanudar + Cancelar + Eliminar + Ofreciendo + Esperando aprobación + Aprobado + Conectando + Interrumpido + Transfiriendo + Rechazado Nombre remoto: %1$s ¿Bloquear a %1$s y rechazar futuros envíos de dispositivos guardados? ¿Bloquear dispositivo? Envía directamente a dispositivos de confianza sin compartir otra invitación. ID del dispositivo: %1$s - No saved devices yet. Finish a transfer, then remember a device. + Finaliza una transferencia y elige recordar el otro dispositivo. + No hay dispositivos guardados ¿Olvidar a %1$s? Ambos deberán volver a aprobar el guardado antes de otra transferencia directa. ¿Olvidar dispositivo? Ready to remember diff --git a/shared/src/commonMain/composeResources/values-fr/strings.xml b/shared/src/commonMain/composeResources/values-fr/strings.xml index b706714..4447b34 100644 --- a/shared/src/commonMain/composeResources/values-fr/strings.xml +++ b/shared/src/commonMain/composeResources/values-fr/strings.xml @@ -319,12 +319,32 @@ %1$s souhaite vous envoyer « %2$s ». Recevoir Refuser + Nécessite votre attention + Vos appareils + Transferts directs + Aucun transfert direct pour le moment. + De %%1$s + Vers %%1$s + %%1$s fichiers · %%2$s + %%1$s sur %%2$s + Recevoir + Reprendre + Annuler + Supprimer + Proposition + En attente d’approbation + Approuvé + Connexion + Interrompu + Transfert + Refusé Nom distant : %1$s Bloquer %1$s et refuser les futurs transferts d’appareil enregistré ? Bloquer l’appareil ? Envoyez directement aux appareils de confiance, sans partager une nouvelle invitation. ID de l’appareil : %1$s - No saved devices yet. Finish a transfer, then remember a device. + Terminez un transfert, puis choisissez de mémoriser l’autre appareil. + Aucun appareil enregistré Oublier %1$s ? Vous devrez tous les deux approuver à nouveau l’enregistrement avant un autre transfert direct. Oublier l’appareil ? Ready to remember diff --git a/shared/src/commonMain/composeResources/values-it/strings.xml b/shared/src/commonMain/composeResources/values-it/strings.xml index 91c20c3..a094328 100644 --- a/shared/src/commonMain/composeResources/values-it/strings.xml +++ b/shared/src/commonMain/composeResources/values-it/strings.xml @@ -319,12 +319,32 @@ %1$s vuole inviarti «%2$s». Ricevi Rifiuta + Richiede attenzione + I tuoi dispositivi + Trasferimenti diretti + Nessun trasferimento diretto. + Da %%1$s + A %%1$s + %%1$s file · %%2$s + %%1$s di %%2$s + Ricevi + Riprendi + Annulla + Elimina + Proposta + In attesa di approvazione + Approvato + Connessione + Interrotto + Trasferimento + Rifiutato Nome remoto: %1$s Bloccare %1$s e rifiutare i futuri trasferimenti da dispositivi salvati? Bloccare il dispositivo? Invia direttamente ai dispositivi attendibili senza condividere un altro invito. ID dispositivo: %1$s - No saved devices yet. Finish a transfer, then remember a device. + Completa un trasferimento, quindi scegli di ricordare l’altro dispositivo. + Nessun dispositivo salvato Dimenticare %1$s? Entrambi dovrete approvare nuovamente il salvataggio prima di un altro trasferimento diretto. Dimenticare il dispositivo? Ready to remember diff --git a/shared/src/commonMain/composeResources/values-nl/strings.xml b/shared/src/commonMain/composeResources/values-nl/strings.xml index 9f9f52d..90d76c4 100644 --- a/shared/src/commonMain/composeResources/values-nl/strings.xml +++ b/shared/src/commonMain/composeResources/values-nl/strings.xml @@ -319,12 +319,32 @@ %1$s wil je “%2$s” sturen. Ontvangen Weigeren + Aandacht vereist + Je apparaten + Directe overdrachten + Nog geen directe overdrachten. + Van %%1$s + Naar %%1$s + %%1$s bestanden · %%2$s + %%1$s van %%2$s + Ontvangen + Hervatten + Annuleren + Verwijderen + Aanbieden + Wachten op goedkeuring + Goedgekeurd + Verbinden + Onderbroken + Overdragen + Geweigerd Externe naam: %1$s %1$s blokkeren en toekomstige overdrachten van opgeslagen apparaten weigeren? Apparaat blokkeren? Stuur rechtstreeks naar vertrouwde apparaten zonder opnieuw een uitnodiging te delen. Apparaat-ID: %1$s - No saved devices yet. Finish a transfer, then remember a device. + Voltooi een overdracht en kies daarna om het andere apparaat te onthouden. + Geen opgeslagen apparaten %1$s vergeten? Jullie moeten het opslaan allebei opnieuw goedkeuren voor een volgende directe overdracht. Apparaat vergeten? Ready to remember diff --git a/shared/src/commonMain/composeResources/values-pl/strings.xml b/shared/src/commonMain/composeResources/values-pl/strings.xml index 6a27333..1568d37 100644 --- a/shared/src/commonMain/composeResources/values-pl/strings.xml +++ b/shared/src/commonMain/composeResources/values-pl/strings.xml @@ -319,12 +319,32 @@ %1$s chce wysłać Ci „%2$s”. Odbierz Odrzuć + Wymaga uwagi + Twoje urządzenia + Transfery bezpośrednie + Brak transferów bezpośrednich. + Od %%1$s + Do %%1$s + %%1$s plików · %%2$s + %%1$s z %%2$s + Odbierz + Wznów + Anuluj + Usuń + Oferowanie + Oczekiwanie na zgodę + Zatwierdzony + Łączenie + Przerwany + Przesyłanie + Odrzucony Nazwa zdalna: %1$s Zablokować %1$s i odrzucać przyszłe transfery z zapisanych urządzeń? Zablokować urządzenie? Wysyłaj bezpośrednio do zaufanych urządzeń bez udostępniania kolejnego zaproszenia. ID urządzenia: %1$s - No saved devices yet. Finish a transfer, then remember a device. + Ukończ transfer, a następnie zapisz drugie urządzenie. + Brak zapisanych urządzeń Zapomnieć %1$s? Przed kolejnym transferem bezpośrednim obie strony muszą ponownie zatwierdzić zapisanie. Zapomnieć urządzenie? Ready to remember diff --git a/shared/src/commonMain/composeResources/values-pt/strings.xml b/shared/src/commonMain/composeResources/values-pt/strings.xml index 4ab7111..3cb6560 100644 --- a/shared/src/commonMain/composeResources/values-pt/strings.xml +++ b/shared/src/commonMain/composeResources/values-pt/strings.xml @@ -319,12 +319,32 @@ %1$s quer enviar-lhe “%2$s”. Receber Recusar + Requer atenção + Os seus dispositivos + Transferências diretas + Ainda não existem transferências diretas. + De %%1$s + Para %%1$s + %%1$s ficheiros · %%2$s + %%1$s de %%2$s + Receber + Retomar + Cancelar + Eliminar + A propor + A aguardar aprovação + Aprovado + A ligar + Interrompido + A transferir + Recusado Nome remoto: %1$s Bloquear %1$s e rejeitar futuras transferências de dispositivos guardados? Bloquear dispositivo? Envie diretamente para dispositivos de confiança sem partilhar outro convite. ID do dispositivo: %1$s - No saved devices yet. Finish a transfer, then remember a device. + Conclua uma transferência e escolha guardar o outro dispositivo. + Nenhum dispositivo guardado Esquecer %1$s? Ambos terão de voltar a aprovar antes de outra transferência direta. Esquecer dispositivo? Ready to remember diff --git a/shared/src/commonMain/composeResources/values-ru/strings.xml b/shared/src/commonMain/composeResources/values-ru/strings.xml index 9b01499..988a7ec 100644 --- a/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -319,12 +319,32 @@ %1$s хочет отправить вам «%2$s». Получить Отклонить + Требует внимания + Ваши устройства + Прямые передачи + Прямых передач пока нет. + От %%1$s + Для %%1$s + %%1$s файлов · %%2$s + %%1$s из %%2$s + Получить + Продолжить + Отменить + Удалить + Предложение + Ожидание подтверждения + Подтверждено + Подключение + Прервано + Передача + Отклонено Имя устройства: %1$s Заблокировать %1$s и отклонять будущие передачи с сохранённых устройств? Заблокировать устройство? Отправляйте напрямую доверенным устройствам без новой ссылки-приглашения. ID устройства: %1$s - No saved devices yet. Finish a transfer, then remember a device. + Завершите передачу, затем сохраните другое устройство. + Нет сохранённых устройств Забыть %1$s? Перед следующей прямой передачей сохранение снова должны подтвердить обе стороны. Забыть устройство? Ready to remember diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 67f5f77..b9325a9 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -319,12 +319,32 @@ %1$s wants to send you “%2$s”. Receive Decline + Needs attention + Your devices + Direct transfers + No direct transfers yet. + From %%1$s + To %%1$s + %%1$s files · %%2$s + %%1$s of %%2$s + Receive + Resume + Cancel + Delete + Offering + Waiting for approval + Approved + Connecting + Interrupted + Transferring + Declined Remote name: %1$s Block %1$s and reject future saved-device transfers? Block device? Send directly to devices you trust, without sharing another invitation. Device ID: %1$s - No saved devices yet. Finish a transfer, then remember a device. + Finish a transfer, then choose to remember the other device. + No saved devices Forget %1$s? You will both need to approve saving again before another direct transfer. Forget device? Ready to remember diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt index 6c2282f..f957b0f 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt @@ -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() diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt index b4662cd..304d917 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt @@ -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, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt index b0f0e22..7ec1ab6 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt @@ -228,6 +228,7 @@ interface CoreGateway { outputSink: ReceiveOutputSinkV2, ): Result suspend fun resumeTargetedTransfer(id: String, outputDir: String): Result + suspend fun resumeTargetedTransferWithOutputSinkV2(id: String, outputSink: ReceiveOutputSinkV2): Result suspend fun cancelTargetedTransfer(id: String): Result suspend fun deleteTargetedTransfer(id: String): Result } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt index 6963607..8cf0003 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt @@ -358,6 +358,13 @@ class CoreRepository internal constructor( activeCore.resumeTargetedTransfer(id, outputDir) } + override suspend fun resumeTargetedTransferWithOutputSinkV2( + id: String, + outputSink: ReceiveOutputSinkV2, + ): Result = runCore { activeCore -> + activeCore.resumeTargetedTransferWithOutputSinkV2(id, outputSink) + } + override suspend fun cancelTargetedTransfer(id: String): Result = runCore { activeCore -> activeCore.cancelTargetedTransfer(id) } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/PairingPromptCoordinator.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/PairingPromptCoordinator.kt deleted file mode 100644 index 21aae91..0000000 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/PairingPromptCoordinator.kt +++ /dev/null @@ -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 = _state.asStateFlow() - - /** Peers whose in-flow eligibility prompt was dismissed this session. */ - private val dismissedEligibility = mutableSetOf() - - 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) - }, - ) - } - } -} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDeviceExperienceModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDeviceExperienceModels.kt new file mode 100644 index 0000000..e6cd5e6 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDeviceExperienceModels.kt @@ -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 = emptyList(), + val senderDisplayNames: Map = emptyMap(), + val respondingIds: Set = 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, +) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDeviceHub.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDeviceHub.kt new file mode 100644 index 0000000..a2f26c3 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDeviceHub.kt @@ -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(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) + "…" diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDeviceTransferHistory.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDeviceTransferHistory.kt new file mode 100644 index 0000000..6dd2e86 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDeviceTransferHistory.kt @@ -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, + busyTransferIds: Set, + 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) + "…" diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesRoute.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesRoute.kt index 7dc9c9d..9973679 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesRoute.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesRoute.kt @@ -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, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesScreen.kt index 9f85ad9..c3233c3 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesScreen.kt @@ -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(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) + "…" diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModel.kt index 71cafb5..2d102ce 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModel.kt @@ -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 = emptyList(), val pendingRelationships: List = emptyList(), val savedDevices: List = emptyList(), + val targetedTransfers: List = emptyList(), + val pairingPrompt: PairingPromptState = PairingPromptState(), + val targetedOffers: TargetedOfferState = TargetedOfferState(), val busyPeerIds: Set = emptySet(), + val busyTransferIds: Set = 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 = _state.asStateFlow() + private val refreshMutex = Mutex() + private val dismissedEligibility = mutableSetOf() + 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 { + 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) { + 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, + eligibilities: List, + savedNames: Map, + ): 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): 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) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/TargetedOfferCoordinator.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/TargetedOfferCoordinator.kt deleted file mode 100644 index f473c64..0000000 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/TargetedOfferCoordinator.kt +++ /dev/null @@ -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 = emptyList(), - val senderDisplayNames: Map = emptyMap(), - val respondingIds: Set = 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 = _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), - ) - } - } -} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/PairingPromptCoordinatorTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/PairingPromptCoordinatorTest.kt deleted file mode 100644 index 71a7137..0000000 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/PairingPromptCoordinatorTest.kt +++ /dev/null @@ -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() - 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(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, - ) -} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModelTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModelTest.kt index fd4e4c4..72747c7 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModelTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModelTest.kt @@ -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(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>("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>("peer-1" to "Kitchen", "peer-1" to null), core.labeledDevices.toList()) - assertNull(viewModel.state.value.savedDevices.single().localLabel) + assertEquals(listOf>("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") + } } diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/TargetedOfferCoordinatorTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/TargetedOfferCoordinatorTest.kt deleted file mode 100644 index 4ea537e..0000000 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/TargetedOfferCoordinatorTest.kt +++ /dev/null @@ -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, - ), - ) -} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt index 49a79b3..fcdf1b4 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt @@ -238,6 +238,11 @@ class FakeCoreGateway : CoreGateway { val receivedTargetedTransferIds = mutableListOf() val receivedTargetedPathDirs = mutableListOf>() val receivedTargetedViaSinkIds = mutableListOf() + val resumedTargetedTransferIds = mutableListOf() + val resumedTargetedPathDirs = mutableListOf>() + val resumedTargetedViaSinkIds = mutableListOf() + val cancelledTargetedTransferIds = mutableListOf() + val deletedTargetedTransferIds = mutableListOf() val respondedTargetedOffers = mutableListOf>() override suspend fun requestSavedDevicePairing(peerEndpointId: String): Result { @@ -284,7 +289,9 @@ class FakeCoreGateway : CoreGateway { } override suspend fun respondToTargetedOffer(transferId: String, accepted: Boolean): Result { 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 { + resumedTargetedTransferIds += id + resumedTargetedPathDirs += id to outputDir + return receiveResult + } + override suspend fun resumeTargetedTransferWithOutputSinkV2( + id: String, + outputSink: ReceiveOutputSinkV2, + ): Result { + resumedTargetedTransferIds += id + resumedTargetedViaSinkIds += id + return receiveResult + } + override suspend fun cancelTargetedTransfer(id: String): Result { + 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 { + deletedTargetedTransferIds += id + targetedTransfers = targetedTransfers.filterNot { it.id == id } + return Result.success(Unit) + } } class FakePreferencesRepository( diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesScreenTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesScreenTest.kt index 77d763d..8aefb9d 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesScreenTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesScreenTest.kt @@ -6,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>() + 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() + 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 = {},