mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-13 05:49:57 +02:00
feat(shared): graduate saved devices experience
This commit is contained in:
@@ -24,6 +24,8 @@ lists, animation, accessibility:
|
||||
3. Open **at most one** file under `.codex/skills/compose-skill/references/` when
|
||||
the skill links to it for the current task.
|
||||
4. Do **not** invent a parallel Compose style guide.
|
||||
5. For visible UI changes, complete the skill's rendered-app visual QA gate.
|
||||
Tests and compilation alone are not a finished UI handoff.
|
||||
|
||||
### Project policy
|
||||
|
||||
|
||||
@@ -319,12 +319,32 @@
|
||||
<string name="offer_body">%1$s möchte Ihnen „%2$s“ senden.</string>
|
||||
<string name="offer_accept">Empfangen</string>
|
||||
<string name="offer_decline">Ablehnen</string>
|
||||
<string name="saved_devices_attention_title">Erfordert Aufmerksamkeit</string>
|
||||
<string name="saved_devices_devices_title">Deine Geräte</string>
|
||||
<string name="saved_devices_transfers_title">Direkte Übertragungen</string>
|
||||
<string name="saved_devices_transfer_empty">Noch keine direkten Übertragungen.</string>
|
||||
<string name="saved_devices_transfer_direction_incoming">Von %%1$s</string>
|
||||
<string name="saved_devices_transfer_direction_outgoing">An %%1$s</string>
|
||||
<string name="saved_devices_transfer_files">%%1$s Dateien · %%2$s</string>
|
||||
<string name="saved_devices_transfer_progress">%%1$s von %%2$s</string>
|
||||
<string name="saved_devices_transfer_receive">Empfangen</string>
|
||||
<string name="saved_devices_transfer_resume">Fortsetzen</string>
|
||||
<string name="saved_devices_transfer_cancel">Abbrechen</string>
|
||||
<string name="saved_devices_transfer_delete">Löschen</string>
|
||||
<string name="status_offering">Wird angeboten</string>
|
||||
<string name="status_awaiting_approval">Wartet auf Genehmigung</string>
|
||||
<string name="status_approved">Genehmigt</string>
|
||||
<string name="status_connecting">Verbindung wird hergestellt</string>
|
||||
<string name="status_interrupted">Unterbrochen</string>
|
||||
<string name="status_transferring">Übertragung läuft</string>
|
||||
<string name="status_declined">Abgelehnt</string>
|
||||
<string name="saved_devices_authenticated_name">Remote-Name: %1$s</string>
|
||||
<string name="saved_devices_block_confirm_body">%1$s blockieren und künftige Übertragungen gespeicherter Geräte ablehnen?</string>
|
||||
<string name="saved_devices_block_confirm_title">Gerät blockieren?</string>
|
||||
<string name="saved_devices_description">Direkt an vertrauenswürdige Geräte senden, ohne eine neue Einladung zu teilen.</string>
|
||||
<string name="saved_devices_endpoint">Geräte-ID: %1$s</string>
|
||||
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
|
||||
<string name="saved_devices_empty">Schließe eine Übertragung ab und speichere anschließend das andere Gerät.</string>
|
||||
<string name="saved_devices_empty_title">Keine gespeicherten Geräte</string>
|
||||
<string name="saved_devices_forget_confirm_body">%1$s vergessen? Vor einer weiteren direkten Übertragung müssen beide das Speichern erneut bestätigen.</string>
|
||||
<string name="saved_devices_forget_confirm_title">Gerät vergessen?</string>
|
||||
<string name="saved_devices_eligibility_title">Ready to remember</string>
|
||||
|
||||
@@ -319,12 +319,32 @@
|
||||
<string name="offer_body">%1$s quiere enviarte «%2$s».</string>
|
||||
<string name="offer_accept">Recibir</string>
|
||||
<string name="offer_decline">Rechazar</string>
|
||||
<string name="saved_devices_attention_title">Requiere atención</string>
|
||||
<string name="saved_devices_devices_title">Tus dispositivos</string>
|
||||
<string name="saved_devices_transfers_title">Transferencias directas</string>
|
||||
<string name="saved_devices_transfer_empty">Aún no hay transferencias directas.</string>
|
||||
<string name="saved_devices_transfer_direction_incoming">De %%1$s</string>
|
||||
<string name="saved_devices_transfer_direction_outgoing">A %%1$s</string>
|
||||
<string name="saved_devices_transfer_files">%%1$s archivos · %%2$s</string>
|
||||
<string name="saved_devices_transfer_progress">%%1$s de %%2$s</string>
|
||||
<string name="saved_devices_transfer_receive">Recibir</string>
|
||||
<string name="saved_devices_transfer_resume">Reanudar</string>
|
||||
<string name="saved_devices_transfer_cancel">Cancelar</string>
|
||||
<string name="saved_devices_transfer_delete">Eliminar</string>
|
||||
<string name="status_offering">Ofreciendo</string>
|
||||
<string name="status_awaiting_approval">Esperando aprobación</string>
|
||||
<string name="status_approved">Aprobado</string>
|
||||
<string name="status_connecting">Conectando</string>
|
||||
<string name="status_interrupted">Interrumpido</string>
|
||||
<string name="status_transferring">Transfiriendo</string>
|
||||
<string name="status_declined">Rechazado</string>
|
||||
<string name="saved_devices_authenticated_name">Nombre remoto: %1$s</string>
|
||||
<string name="saved_devices_block_confirm_body">¿Bloquear a %1$s y rechazar futuros envíos de dispositivos guardados?</string>
|
||||
<string name="saved_devices_block_confirm_title">¿Bloquear dispositivo?</string>
|
||||
<string name="saved_devices_description">Envía directamente a dispositivos de confianza sin compartir otra invitación.</string>
|
||||
<string name="saved_devices_endpoint">ID del dispositivo: %1$s</string>
|
||||
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
|
||||
<string name="saved_devices_empty">Finaliza una transferencia y elige recordar el otro dispositivo.</string>
|
||||
<string name="saved_devices_empty_title">No hay dispositivos guardados</string>
|
||||
<string name="saved_devices_forget_confirm_body">¿Olvidar a %1$s? Ambos deberán volver a aprobar el guardado antes de otra transferencia directa.</string>
|
||||
<string name="saved_devices_forget_confirm_title">¿Olvidar dispositivo?</string>
|
||||
<string name="saved_devices_eligibility_title">Ready to remember</string>
|
||||
|
||||
@@ -319,12 +319,32 @@
|
||||
<string name="offer_body">%1$s souhaite vous envoyer « %2$s ».</string>
|
||||
<string name="offer_accept">Recevoir</string>
|
||||
<string name="offer_decline">Refuser</string>
|
||||
<string name="saved_devices_attention_title">Nécessite votre attention</string>
|
||||
<string name="saved_devices_devices_title">Vos appareils</string>
|
||||
<string name="saved_devices_transfers_title">Transferts directs</string>
|
||||
<string name="saved_devices_transfer_empty">Aucun transfert direct pour le moment.</string>
|
||||
<string name="saved_devices_transfer_direction_incoming">De %%1$s</string>
|
||||
<string name="saved_devices_transfer_direction_outgoing">Vers %%1$s</string>
|
||||
<string name="saved_devices_transfer_files">%%1$s fichiers · %%2$s</string>
|
||||
<string name="saved_devices_transfer_progress">%%1$s sur %%2$s</string>
|
||||
<string name="saved_devices_transfer_receive">Recevoir</string>
|
||||
<string name="saved_devices_transfer_resume">Reprendre</string>
|
||||
<string name="saved_devices_transfer_cancel">Annuler</string>
|
||||
<string name="saved_devices_transfer_delete">Supprimer</string>
|
||||
<string name="status_offering">Proposition</string>
|
||||
<string name="status_awaiting_approval">En attente d’approbation</string>
|
||||
<string name="status_approved">Approuvé</string>
|
||||
<string name="status_connecting">Connexion</string>
|
||||
<string name="status_interrupted">Interrompu</string>
|
||||
<string name="status_transferring">Transfert</string>
|
||||
<string name="status_declined">Refusé</string>
|
||||
<string name="saved_devices_authenticated_name">Nom distant : %1$s</string>
|
||||
<string name="saved_devices_block_confirm_body">Bloquer %1$s et refuser les futurs transferts d’appareil enregistré ?</string>
|
||||
<string name="saved_devices_block_confirm_title">Bloquer l’appareil ?</string>
|
||||
<string name="saved_devices_description">Envoyez directement aux appareils de confiance, sans partager une nouvelle invitation.</string>
|
||||
<string name="saved_devices_endpoint">ID de l’appareil : %1$s</string>
|
||||
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
|
||||
<string name="saved_devices_empty">Terminez un transfert, puis choisissez de mémoriser l’autre appareil.</string>
|
||||
<string name="saved_devices_empty_title">Aucun appareil enregistré</string>
|
||||
<string name="saved_devices_forget_confirm_body">Oublier %1$s ? Vous devrez tous les deux approuver à nouveau l’enregistrement avant un autre transfert direct.</string>
|
||||
<string name="saved_devices_forget_confirm_title">Oublier l’appareil ?</string>
|
||||
<string name="saved_devices_eligibility_title">Ready to remember</string>
|
||||
|
||||
@@ -319,12 +319,32 @@
|
||||
<string name="offer_body">%1$s vuole inviarti «%2$s».</string>
|
||||
<string name="offer_accept">Ricevi</string>
|
||||
<string name="offer_decline">Rifiuta</string>
|
||||
<string name="saved_devices_attention_title">Richiede attenzione</string>
|
||||
<string name="saved_devices_devices_title">I tuoi dispositivi</string>
|
||||
<string name="saved_devices_transfers_title">Trasferimenti diretti</string>
|
||||
<string name="saved_devices_transfer_empty">Nessun trasferimento diretto.</string>
|
||||
<string name="saved_devices_transfer_direction_incoming">Da %%1$s</string>
|
||||
<string name="saved_devices_transfer_direction_outgoing">A %%1$s</string>
|
||||
<string name="saved_devices_transfer_files">%%1$s file · %%2$s</string>
|
||||
<string name="saved_devices_transfer_progress">%%1$s di %%2$s</string>
|
||||
<string name="saved_devices_transfer_receive">Ricevi</string>
|
||||
<string name="saved_devices_transfer_resume">Riprendi</string>
|
||||
<string name="saved_devices_transfer_cancel">Annulla</string>
|
||||
<string name="saved_devices_transfer_delete">Elimina</string>
|
||||
<string name="status_offering">Proposta</string>
|
||||
<string name="status_awaiting_approval">In attesa di approvazione</string>
|
||||
<string name="status_approved">Approvato</string>
|
||||
<string name="status_connecting">Connessione</string>
|
||||
<string name="status_interrupted">Interrotto</string>
|
||||
<string name="status_transferring">Trasferimento</string>
|
||||
<string name="status_declined">Rifiutato</string>
|
||||
<string name="saved_devices_authenticated_name">Nome remoto: %1$s</string>
|
||||
<string name="saved_devices_block_confirm_body">Bloccare %1$s e rifiutare i futuri trasferimenti da dispositivi salvati?</string>
|
||||
<string name="saved_devices_block_confirm_title">Bloccare il dispositivo?</string>
|
||||
<string name="saved_devices_description">Invia direttamente ai dispositivi attendibili senza condividere un altro invito.</string>
|
||||
<string name="saved_devices_endpoint">ID dispositivo: %1$s</string>
|
||||
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
|
||||
<string name="saved_devices_empty">Completa un trasferimento, quindi scegli di ricordare l’altro dispositivo.</string>
|
||||
<string name="saved_devices_empty_title">Nessun dispositivo salvato</string>
|
||||
<string name="saved_devices_forget_confirm_body">Dimenticare %1$s? Entrambi dovrete approvare nuovamente il salvataggio prima di un altro trasferimento diretto.</string>
|
||||
<string name="saved_devices_forget_confirm_title">Dimenticare il dispositivo?</string>
|
||||
<string name="saved_devices_eligibility_title">Ready to remember</string>
|
||||
|
||||
@@ -319,12 +319,32 @@
|
||||
<string name="offer_body">%1$s wil je “%2$s” sturen.</string>
|
||||
<string name="offer_accept">Ontvangen</string>
|
||||
<string name="offer_decline">Weigeren</string>
|
||||
<string name="saved_devices_attention_title">Aandacht vereist</string>
|
||||
<string name="saved_devices_devices_title">Je apparaten</string>
|
||||
<string name="saved_devices_transfers_title">Directe overdrachten</string>
|
||||
<string name="saved_devices_transfer_empty">Nog geen directe overdrachten.</string>
|
||||
<string name="saved_devices_transfer_direction_incoming">Van %%1$s</string>
|
||||
<string name="saved_devices_transfer_direction_outgoing">Naar %%1$s</string>
|
||||
<string name="saved_devices_transfer_files">%%1$s bestanden · %%2$s</string>
|
||||
<string name="saved_devices_transfer_progress">%%1$s van %%2$s</string>
|
||||
<string name="saved_devices_transfer_receive">Ontvangen</string>
|
||||
<string name="saved_devices_transfer_resume">Hervatten</string>
|
||||
<string name="saved_devices_transfer_cancel">Annuleren</string>
|
||||
<string name="saved_devices_transfer_delete">Verwijderen</string>
|
||||
<string name="status_offering">Aanbieden</string>
|
||||
<string name="status_awaiting_approval">Wachten op goedkeuring</string>
|
||||
<string name="status_approved">Goedgekeurd</string>
|
||||
<string name="status_connecting">Verbinden</string>
|
||||
<string name="status_interrupted">Onderbroken</string>
|
||||
<string name="status_transferring">Overdragen</string>
|
||||
<string name="status_declined">Geweigerd</string>
|
||||
<string name="saved_devices_authenticated_name">Externe naam: %1$s</string>
|
||||
<string name="saved_devices_block_confirm_body">%1$s blokkeren en toekomstige overdrachten van opgeslagen apparaten weigeren?</string>
|
||||
<string name="saved_devices_block_confirm_title">Apparaat blokkeren?</string>
|
||||
<string name="saved_devices_description">Stuur rechtstreeks naar vertrouwde apparaten zonder opnieuw een uitnodiging te delen.</string>
|
||||
<string name="saved_devices_endpoint">Apparaat-ID: %1$s</string>
|
||||
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
|
||||
<string name="saved_devices_empty">Voltooi een overdracht en kies daarna om het andere apparaat te onthouden.</string>
|
||||
<string name="saved_devices_empty_title">Geen opgeslagen apparaten</string>
|
||||
<string name="saved_devices_forget_confirm_body">%1$s vergeten? Jullie moeten het opslaan allebei opnieuw goedkeuren voor een volgende directe overdracht.</string>
|
||||
<string name="saved_devices_forget_confirm_title">Apparaat vergeten?</string>
|
||||
<string name="saved_devices_eligibility_title">Ready to remember</string>
|
||||
|
||||
@@ -319,12 +319,32 @@
|
||||
<string name="offer_body">%1$s chce wysłać Ci „%2$s”.</string>
|
||||
<string name="offer_accept">Odbierz</string>
|
||||
<string name="offer_decline">Odrzuć</string>
|
||||
<string name="saved_devices_attention_title">Wymaga uwagi</string>
|
||||
<string name="saved_devices_devices_title">Twoje urządzenia</string>
|
||||
<string name="saved_devices_transfers_title">Transfery bezpośrednie</string>
|
||||
<string name="saved_devices_transfer_empty">Brak transferów bezpośrednich.</string>
|
||||
<string name="saved_devices_transfer_direction_incoming">Od %%1$s</string>
|
||||
<string name="saved_devices_transfer_direction_outgoing">Do %%1$s</string>
|
||||
<string name="saved_devices_transfer_files">%%1$s plików · %%2$s</string>
|
||||
<string name="saved_devices_transfer_progress">%%1$s z %%2$s</string>
|
||||
<string name="saved_devices_transfer_receive">Odbierz</string>
|
||||
<string name="saved_devices_transfer_resume">Wznów</string>
|
||||
<string name="saved_devices_transfer_cancel">Anuluj</string>
|
||||
<string name="saved_devices_transfer_delete">Usuń</string>
|
||||
<string name="status_offering">Oferowanie</string>
|
||||
<string name="status_awaiting_approval">Oczekiwanie na zgodę</string>
|
||||
<string name="status_approved">Zatwierdzony</string>
|
||||
<string name="status_connecting">Łączenie</string>
|
||||
<string name="status_interrupted">Przerwany</string>
|
||||
<string name="status_transferring">Przesyłanie</string>
|
||||
<string name="status_declined">Odrzucony</string>
|
||||
<string name="saved_devices_authenticated_name">Nazwa zdalna: %1$s</string>
|
||||
<string name="saved_devices_block_confirm_body">Zablokować %1$s i odrzucać przyszłe transfery z zapisanych urządzeń?</string>
|
||||
<string name="saved_devices_block_confirm_title">Zablokować urządzenie?</string>
|
||||
<string name="saved_devices_description">Wysyłaj bezpośrednio do zaufanych urządzeń bez udostępniania kolejnego zaproszenia.</string>
|
||||
<string name="saved_devices_endpoint">ID urządzenia: %1$s</string>
|
||||
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
|
||||
<string name="saved_devices_empty">Ukończ transfer, a następnie zapisz drugie urządzenie.</string>
|
||||
<string name="saved_devices_empty_title">Brak zapisanych urządzeń</string>
|
||||
<string name="saved_devices_forget_confirm_body">Zapomnieć %1$s? Przed kolejnym transferem bezpośrednim obie strony muszą ponownie zatwierdzić zapisanie.</string>
|
||||
<string name="saved_devices_forget_confirm_title">Zapomnieć urządzenie?</string>
|
||||
<string name="saved_devices_eligibility_title">Ready to remember</string>
|
||||
|
||||
@@ -319,12 +319,32 @@
|
||||
<string name="offer_body">%1$s quer enviar-lhe “%2$s”.</string>
|
||||
<string name="offer_accept">Receber</string>
|
||||
<string name="offer_decline">Recusar</string>
|
||||
<string name="saved_devices_attention_title">Requer atenção</string>
|
||||
<string name="saved_devices_devices_title">Os seus dispositivos</string>
|
||||
<string name="saved_devices_transfers_title">Transferências diretas</string>
|
||||
<string name="saved_devices_transfer_empty">Ainda não existem transferências diretas.</string>
|
||||
<string name="saved_devices_transfer_direction_incoming">De %%1$s</string>
|
||||
<string name="saved_devices_transfer_direction_outgoing">Para %%1$s</string>
|
||||
<string name="saved_devices_transfer_files">%%1$s ficheiros · %%2$s</string>
|
||||
<string name="saved_devices_transfer_progress">%%1$s de %%2$s</string>
|
||||
<string name="saved_devices_transfer_receive">Receber</string>
|
||||
<string name="saved_devices_transfer_resume">Retomar</string>
|
||||
<string name="saved_devices_transfer_cancel">Cancelar</string>
|
||||
<string name="saved_devices_transfer_delete">Eliminar</string>
|
||||
<string name="status_offering">A propor</string>
|
||||
<string name="status_awaiting_approval">A aguardar aprovação</string>
|
||||
<string name="status_approved">Aprovado</string>
|
||||
<string name="status_connecting">A ligar</string>
|
||||
<string name="status_interrupted">Interrompido</string>
|
||||
<string name="status_transferring">A transferir</string>
|
||||
<string name="status_declined">Recusado</string>
|
||||
<string name="saved_devices_authenticated_name">Nome remoto: %1$s</string>
|
||||
<string name="saved_devices_block_confirm_body">Bloquear %1$s e rejeitar futuras transferências de dispositivos guardados?</string>
|
||||
<string name="saved_devices_block_confirm_title">Bloquear dispositivo?</string>
|
||||
<string name="saved_devices_description">Envie diretamente para dispositivos de confiança sem partilhar outro convite.</string>
|
||||
<string name="saved_devices_endpoint">ID do dispositivo: %1$s</string>
|
||||
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
|
||||
<string name="saved_devices_empty">Conclua uma transferência e escolha guardar o outro dispositivo.</string>
|
||||
<string name="saved_devices_empty_title">Nenhum dispositivo guardado</string>
|
||||
<string name="saved_devices_forget_confirm_body">Esquecer %1$s? Ambos terão de voltar a aprovar antes de outra transferência direta.</string>
|
||||
<string name="saved_devices_forget_confirm_title">Esquecer dispositivo?</string>
|
||||
<string name="saved_devices_eligibility_title">Ready to remember</string>
|
||||
|
||||
@@ -319,12 +319,32 @@
|
||||
<string name="offer_body">%1$s хочет отправить вам «%2$s».</string>
|
||||
<string name="offer_accept">Получить</string>
|
||||
<string name="offer_decline">Отклонить</string>
|
||||
<string name="saved_devices_attention_title">Требует внимания</string>
|
||||
<string name="saved_devices_devices_title">Ваши устройства</string>
|
||||
<string name="saved_devices_transfers_title">Прямые передачи</string>
|
||||
<string name="saved_devices_transfer_empty">Прямых передач пока нет.</string>
|
||||
<string name="saved_devices_transfer_direction_incoming">От %%1$s</string>
|
||||
<string name="saved_devices_transfer_direction_outgoing">Для %%1$s</string>
|
||||
<string name="saved_devices_transfer_files">%%1$s файлов · %%2$s</string>
|
||||
<string name="saved_devices_transfer_progress">%%1$s из %%2$s</string>
|
||||
<string name="saved_devices_transfer_receive">Получить</string>
|
||||
<string name="saved_devices_transfer_resume">Продолжить</string>
|
||||
<string name="saved_devices_transfer_cancel">Отменить</string>
|
||||
<string name="saved_devices_transfer_delete">Удалить</string>
|
||||
<string name="status_offering">Предложение</string>
|
||||
<string name="status_awaiting_approval">Ожидание подтверждения</string>
|
||||
<string name="status_approved">Подтверждено</string>
|
||||
<string name="status_connecting">Подключение</string>
|
||||
<string name="status_interrupted">Прервано</string>
|
||||
<string name="status_transferring">Передача</string>
|
||||
<string name="status_declined">Отклонено</string>
|
||||
<string name="saved_devices_authenticated_name">Имя устройства: %1$s</string>
|
||||
<string name="saved_devices_block_confirm_body">Заблокировать %1$s и отклонять будущие передачи с сохранённых устройств?</string>
|
||||
<string name="saved_devices_block_confirm_title">Заблокировать устройство?</string>
|
||||
<string name="saved_devices_description">Отправляйте напрямую доверенным устройствам без новой ссылки-приглашения.</string>
|
||||
<string name="saved_devices_endpoint">ID устройства: %1$s</string>
|
||||
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
|
||||
<string name="saved_devices_empty">Завершите передачу, затем сохраните другое устройство.</string>
|
||||
<string name="saved_devices_empty_title">Нет сохранённых устройств</string>
|
||||
<string name="saved_devices_forget_confirm_body">Забыть %1$s? Перед следующей прямой передачей сохранение снова должны подтвердить обе стороны.</string>
|
||||
<string name="saved_devices_forget_confirm_title">Забыть устройство?</string>
|
||||
<string name="saved_devices_eligibility_title">Ready to remember</string>
|
||||
|
||||
@@ -319,12 +319,32 @@
|
||||
<string name="offer_body">%1$s wants to send you “%2$s”.</string>
|
||||
<string name="offer_accept">Receive</string>
|
||||
<string name="offer_decline">Decline</string>
|
||||
<string name="saved_devices_attention_title">Needs attention</string>
|
||||
<string name="saved_devices_devices_title">Your devices</string>
|
||||
<string name="saved_devices_transfers_title">Direct transfers</string>
|
||||
<string name="saved_devices_transfer_empty">No direct transfers yet.</string>
|
||||
<string name="saved_devices_transfer_direction_incoming">From %%1$s</string>
|
||||
<string name="saved_devices_transfer_direction_outgoing">To %%1$s</string>
|
||||
<string name="saved_devices_transfer_files">%%1$s files · %%2$s</string>
|
||||
<string name="saved_devices_transfer_progress">%%1$s of %%2$s</string>
|
||||
<string name="saved_devices_transfer_receive">Receive</string>
|
||||
<string name="saved_devices_transfer_resume">Resume</string>
|
||||
<string name="saved_devices_transfer_cancel">Cancel</string>
|
||||
<string name="saved_devices_transfer_delete">Delete</string>
|
||||
<string name="status_offering">Offering</string>
|
||||
<string name="status_awaiting_approval">Waiting for approval</string>
|
||||
<string name="status_approved">Approved</string>
|
||||
<string name="status_connecting">Connecting</string>
|
||||
<string name="status_interrupted">Interrupted</string>
|
||||
<string name="status_transferring">Transferring</string>
|
||||
<string name="status_declined">Declined</string>
|
||||
<string name="saved_devices_authenticated_name">Remote name: %1$s</string>
|
||||
<string name="saved_devices_block_confirm_body">Block %1$s and reject future saved-device transfers?</string>
|
||||
<string name="saved_devices_block_confirm_title">Block device?</string>
|
||||
<string name="saved_devices_description">Send directly to devices you trust, without sharing another invitation.</string>
|
||||
<string name="saved_devices_endpoint">Device ID: %1$s</string>
|
||||
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
|
||||
<string name="saved_devices_empty">Finish a transfer, then choose to remember the other device.</string>
|
||||
<string name="saved_devices_empty_title">No saved devices</string>
|
||||
<string name="saved_devices_forget_confirm_body">Forget %1$s? You will both need to approve saving again before another direct transfer.</string>
|
||||
<string name="saved_devices_forget_confirm_title">Forget device?</string>
|
||||
<string name="saved_devices_eligibility_title">Ready to remember</string>
|
||||
|
||||
@@ -132,6 +132,8 @@ fun App(
|
||||
val savedDevicesViewModel = viewModel {
|
||||
SavedDevicesViewModel(
|
||||
graph.coreRepository,
|
||||
dependencies.fileSystemService,
|
||||
graph.preferencesRepository,
|
||||
graph.messages,
|
||||
)
|
||||
}
|
||||
@@ -141,8 +143,7 @@ fun App(
|
||||
val receiveState by receiveViewModel.state.collectAsStateWithLifecycle()
|
||||
val receiveCoreState by receiveViewModel.coreState.collectAsStateWithLifecycle()
|
||||
val approvalState by graph.approvalCoordinator.state.collectAsStateWithLifecycle()
|
||||
val pairingPromptState by graph.pairingPromptCoordinator.state.collectAsStateWithLifecycle()
|
||||
val targetedOfferState by graph.targetedOfferCoordinator.state.collectAsStateWithLifecycle()
|
||||
val savedDevicesState by savedDevicesViewModel.state.collectAsStateWithLifecycle()
|
||||
val username by graph.preferencesRepository.preferences
|
||||
.map { it.username }
|
||||
.collectAsStateWithLifecycle(initialValue = dependencies.environment.defaultUsername)
|
||||
@@ -266,17 +267,19 @@ fun App(
|
||||
onAccept = graph.approvalCoordinator::accept,
|
||||
onRefuse = graph.approvalCoordinator::refuse,
|
||||
)
|
||||
PairingPromptHost(
|
||||
state = pairingPromptState,
|
||||
onAccept = graph.pairingPromptCoordinator::accept,
|
||||
onDecline = graph.pairingPromptCoordinator::decline,
|
||||
onDismiss = graph.pairingPromptCoordinator::dismiss,
|
||||
)
|
||||
TargetedOfferModalHost(
|
||||
state = targetedOfferState,
|
||||
onAccept = graph.targetedOfferCoordinator::accept,
|
||||
onDecline = graph.targetedOfferCoordinator::decline,
|
||||
)
|
||||
if (appState.destination != AppDestination.SavedDevices) {
|
||||
PairingPromptHost(
|
||||
state = savedDevicesState.pairingPrompt,
|
||||
onAccept = savedDevicesViewModel::acceptPairingPrompt,
|
||||
onDecline = savedDevicesViewModel::declinePairingPrompt,
|
||||
onDismiss = savedDevicesViewModel::dismissPairingPrompt,
|
||||
)
|
||||
TargetedOfferModalHost(
|
||||
state = savedDevicesState.targetedOffers,
|
||||
onAccept = savedDevicesViewModel::acceptTargetedOffer,
|
||||
onDecline = savedDevicesViewModel::declineTargetedOffer,
|
||||
)
|
||||
}
|
||||
TransferDraftHost(targetedDraftViewModel, windowClass, onCreated = {})
|
||||
}
|
||||
windowChrome?.invoke()
|
||||
|
||||
@@ -5,8 +5,6 @@ import com.vnidrop.app.core.CoreRepository
|
||||
import com.vnidrop.app.diagnostics.DiagnosticsCoordinator
|
||||
import com.vnidrop.app.diagnostics.createDiagnosticsTransport
|
||||
import com.vnidrop.app.feature.approvals.ApprovalCoordinator
|
||||
import com.vnidrop.app.feature.saveddevices.PairingPromptCoordinator
|
||||
import com.vnidrop.app.feature.saveddevices.TargetedOfferCoordinator
|
||||
import com.vnidrop.app.feature.send.AppFilePreviewRepository
|
||||
import com.vnidrop.app.feature.send.createPlatformPreviewStore
|
||||
import com.vnidrop.app.logging.AppLogger
|
||||
@@ -60,18 +58,6 @@ class AppGraph(
|
||||
messages = messages,
|
||||
scope = applicationScope,
|
||||
)
|
||||
val pairingPromptCoordinator = PairingPromptCoordinator(
|
||||
repository = coreRepository,
|
||||
messages = messages,
|
||||
scope = applicationScope,
|
||||
)
|
||||
val targetedOfferCoordinator = TargetedOfferCoordinator(
|
||||
repository = coreRepository,
|
||||
fileSystemService = dependencies.fileSystemService,
|
||||
preferencesRepository = preferencesRepository,
|
||||
messages = messages,
|
||||
scope = applicationScope,
|
||||
)
|
||||
val transferNotificationCoordinator = TransferNotificationCoordinator(
|
||||
repository = coreRepository,
|
||||
preferencesRepository = preferencesRepository,
|
||||
|
||||
@@ -228,6 +228,7 @@ interface CoreGateway {
|
||||
outputSink: ReceiveOutputSinkV2,
|
||||
): Result<Unit>
|
||||
suspend fun resumeTargetedTransfer(id: String, outputDir: String): Result<Unit>
|
||||
suspend fun resumeTargetedTransferWithOutputSinkV2(id: String, outputSink: ReceiveOutputSinkV2): Result<Unit>
|
||||
suspend fun cancelTargetedTransfer(id: String): Result<Unit>
|
||||
suspend fun deleteTargetedTransfer(id: String): Result<Unit>
|
||||
}
|
||||
|
||||
@@ -358,6 +358,13 @@ class CoreRepository internal constructor(
|
||||
activeCore.resumeTargetedTransfer(id, outputDir)
|
||||
}
|
||||
|
||||
override suspend fun resumeTargetedTransferWithOutputSinkV2(
|
||||
id: String,
|
||||
outputSink: ReceiveOutputSinkV2,
|
||||
): Result<Unit> = runCore { activeCore ->
|
||||
activeCore.resumeTargetedTransferWithOutputSinkV2(id, outputSink)
|
||||
}
|
||||
|
||||
override suspend fun cancelTargetedTransfer(id: String): Result<Unit> = runCore { activeCore ->
|
||||
activeCore.cancelTargetedTransfer(id)
|
||||
}
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
package com.vnidrop.app.feature.saveddevices
|
||||
|
||||
import com.vnidrop.app.core.CoreGateway
|
||||
import com.vnidrop.app.core.CoreSignal
|
||||
import com.vnidrop.app.core.DeviceRelationshipStateModel
|
||||
import com.vnidrop.app.ui.feedback.UiMessageController
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed interface PairingPrompt {
|
||||
/** Local eligibility after a completed invitation transfer — user may remember the peer. */
|
||||
data class Eligibility(val peerEndpointId: String, val remoteDisplayName: String?) : PairingPrompt
|
||||
|
||||
/** Peer requested pairing; user may accept or decline mutual consent. */
|
||||
data class IncomingRequest(val peerEndpointId: String, val remoteDisplayName: String?) : PairingPrompt
|
||||
}
|
||||
|
||||
data class PairingPromptState(
|
||||
val prompt: PairingPrompt? = null,
|
||||
val busy: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Foreground in-flow pairing prompts. Dismiss keeps durable eligibility /
|
||||
* pending relationship visible in the Saved devices area; Decline consumes it.
|
||||
*/
|
||||
class PairingPromptCoordinator(
|
||||
private val repository: CoreGateway,
|
||||
private val messages: UiMessageController,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val _state = MutableStateFlow(PairingPromptState())
|
||||
val state: StateFlow<PairingPromptState> = _state.asStateFlow()
|
||||
|
||||
/** Peers whose in-flow eligibility prompt was dismissed this session. */
|
||||
private val dismissedEligibility = mutableSetOf<String>()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
repository.state.map { it.isInitialized }
|
||||
.distinctUntilChanged()
|
||||
.collect { initialized ->
|
||||
if (initialized) refresh()
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
repository.signals.collect { signal ->
|
||||
when (signal) {
|
||||
CoreSignal.PairingChanged -> {
|
||||
if (repository.state.value.isInitialized) refresh()
|
||||
}
|
||||
is CoreSignal.ApprovalChanged,
|
||||
is CoreSignal.ReceiverHistoryChanged,
|
||||
is CoreSignal.TransfersChanged,
|
||||
CoreSignal.TargetedTransferChanged -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun accept() {
|
||||
val prompt = _state.value.prompt ?: return
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true) }
|
||||
scope.launch {
|
||||
val result = when (prompt) {
|
||||
is PairingPrompt.Eligibility -> repository.requestSavedDevicePairing(prompt.peerEndpointId)
|
||||
is PairingPrompt.IncomingRequest ->
|
||||
repository.respondToDevicePairing(prompt.peerEndpointId, accepted = true)
|
||||
}
|
||||
_state.update { it.copy(busy = false) }
|
||||
result.fold(
|
||||
onSuccess = { refresh() },
|
||||
onFailure = messages::error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun decline() {
|
||||
val prompt = _state.value.prompt ?: return
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true) }
|
||||
scope.launch {
|
||||
val result = when (prompt) {
|
||||
is PairingPrompt.Eligibility -> repository.declinePairingEligibility(prompt.peerEndpointId)
|
||||
is PairingPrompt.IncomingRequest ->
|
||||
repository.respondToDevicePairing(prompt.peerEndpointId, accepted = false)
|
||||
}
|
||||
_state.update { it.copy(busy = false) }
|
||||
result.fold(
|
||||
onSuccess = { refresh() },
|
||||
onFailure = messages::error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the dialog without consuming durable eligibility / pending state. */
|
||||
fun dismiss() {
|
||||
val prompt = _state.value.prompt ?: return
|
||||
if (prompt is PairingPrompt.Eligibility) {
|
||||
dismissedEligibility += prompt.peerEndpointId
|
||||
}
|
||||
_state.update { it.copy(prompt = null) }
|
||||
}
|
||||
|
||||
private suspend fun refresh() {
|
||||
if (_state.value.busy) return
|
||||
val relationships = repository.listDeviceRelationships().getOrElse {
|
||||
messages.error(it)
|
||||
return
|
||||
}
|
||||
val incoming = relationships.firstOrNull { it.state == DeviceRelationshipStateModel.PendingIncoming }
|
||||
if (incoming != null) {
|
||||
val remoteDisplayName = repository.listPairingEligibilities()
|
||||
.getOrElse {
|
||||
messages.error(it)
|
||||
emptyList()
|
||||
}
|
||||
.firstOrNull { eligibility -> eligibility.peerEndpointId == incoming.remoteEndpointId }
|
||||
?.remoteDisplayName
|
||||
_state.update {
|
||||
it.copy(
|
||||
prompt = PairingPrompt.IncomingRequest(
|
||||
incoming.remoteEndpointId,
|
||||
remoteDisplayName,
|
||||
),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
val eligibilities = repository.listPairingEligibilities().getOrElse {
|
||||
messages.error(it)
|
||||
return
|
||||
}
|
||||
val eligibility = eligibilities.firstOrNull { it.peerEndpointId !in dismissedEligibility }
|
||||
_state.update {
|
||||
it.copy(
|
||||
prompt = eligibility?.let { row ->
|
||||
PairingPrompt.Eligibility(row.peerEndpointId, row.remoteDisplayName)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.vnidrop.app.feature.saveddevices
|
||||
|
||||
import com.vnidrop.app.core.PendingTargetedOfferModel
|
||||
import com.vnidrop.app.core.TargetedTransferStateModel
|
||||
|
||||
sealed interface PairingPrompt {
|
||||
data class Eligibility(val peerEndpointId: String, val remoteDisplayName: String?) : PairingPrompt
|
||||
|
||||
data class IncomingRequest(val peerEndpointId: String, val remoteDisplayName: String?) : PairingPrompt
|
||||
}
|
||||
|
||||
data class PairingPromptState(
|
||||
val prompt: PairingPrompt? = null,
|
||||
val busy: Boolean = false,
|
||||
)
|
||||
|
||||
data class TargetedOfferState(
|
||||
val pending: List<PendingTargetedOfferModel> = emptyList(),
|
||||
val senderDisplayNames: Map<String, String> = emptyMap(),
|
||||
val respondingIds: Set<String> = emptySet(),
|
||||
) {
|
||||
val current: PendingTargetedOfferModel?
|
||||
get() = pending.firstOrNull()
|
||||
|
||||
val currentSenderDisplayName: String?
|
||||
get() = current?.senderEndpointId?.let(senderDisplayNames::get)
|
||||
}
|
||||
|
||||
enum class SavedDeviceTransferDirection {
|
||||
Outgoing,
|
||||
Incoming,
|
||||
}
|
||||
|
||||
data class SavedDeviceTransferItem(
|
||||
val id: String,
|
||||
val peerEndpointId: String,
|
||||
val peerDisplayName: String?,
|
||||
val direction: SavedDeviceTransferDirection,
|
||||
val transferName: String,
|
||||
val fileCount: ULong,
|
||||
val totalSize: ULong,
|
||||
val verifiedBytes: ULong,
|
||||
val state: TargetedTransferStateModel,
|
||||
val createdAt: Long,
|
||||
val updatedAt: Long,
|
||||
)
|
||||
@@ -0,0 +1,544 @@
|
||||
package com.vnidrop.app.feature.saveddevices
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vnidrop.app.core.DeviceRelationshipModel
|
||||
import com.vnidrop.app.core.DeviceRelationshipStateModel
|
||||
import com.vnidrop.app.core.PairingEligibilityModel
|
||||
import com.vnidrop.app.core.PendingTargetedOfferModel
|
||||
import com.vnidrop.app.core.SavedDeviceModel
|
||||
import com.vnidrop.app.ui.components.PrimaryButton
|
||||
import com.vnidrop.app.ui.components.SecondaryButton
|
||||
import com.vnidrop.app.ui.icons.AppIcon
|
||||
import com.vnidrop.app.ui.icons.PlatformIcon
|
||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.button_cancel
|
||||
import vnidrop.shared.generated.resources.button_retry
|
||||
import vnidrop.shared.generated.resources.offer_accept
|
||||
import vnidrop.shared.generated.resources.offer_body
|
||||
import vnidrop.shared.generated.resources.offer_decline
|
||||
import vnidrop.shared.generated.resources.offer_title
|
||||
import vnidrop.shared.generated.resources.saved_devices_accept_pairing_action
|
||||
import vnidrop.shared.generated.resources.saved_devices_attention_title
|
||||
import vnidrop.shared.generated.resources.saved_devices_authenticated_name
|
||||
import vnidrop.shared.generated.resources.saved_devices_block_action
|
||||
import vnidrop.shared.generated.resources.saved_devices_block_confirm_body
|
||||
import vnidrop.shared.generated.resources.saved_devices_block_confirm_title
|
||||
import vnidrop.shared.generated.resources.saved_devices_decline_action
|
||||
import vnidrop.shared.generated.resources.saved_devices_devices_title
|
||||
import vnidrop.shared.generated.resources.saved_devices_eligibility_title
|
||||
import vnidrop.shared.generated.resources.saved_devices_empty
|
||||
import vnidrop.shared.generated.resources.saved_devices_empty_title
|
||||
import vnidrop.shared.generated.resources.saved_devices_endpoint
|
||||
import vnidrop.shared.generated.resources.saved_devices_forget_action
|
||||
import vnidrop.shared.generated.resources.saved_devices_forget_confirm_body
|
||||
import vnidrop.shared.generated.resources.saved_devices_forget_confirm_title
|
||||
import vnidrop.shared.generated.resources.saved_devices_label_action
|
||||
import vnidrop.shared.generated.resources.saved_devices_load_failed
|
||||
import vnidrop.shared.generated.resources.saved_devices_more_actions
|
||||
import vnidrop.shared.generated.resources.saved_devices_pending_incoming
|
||||
import vnidrop.shared.generated.resources.saved_devices_pending_outgoing
|
||||
import vnidrop.shared.generated.resources.saved_devices_remember_action
|
||||
import vnidrop.shared.generated.resources.saved_devices_send_action
|
||||
import vnidrop.shared.generated.resources.saved_devices_transfer_empty
|
||||
import vnidrop.shared.generated.resources.saved_devices_transfers_title
|
||||
import vnidrop.shared.generated.resources.saved_devices_unnamed
|
||||
|
||||
@Composable
|
||||
internal fun CompactSavedDevicesHub(
|
||||
state: SavedDevicesState,
|
||||
onRetry: () -> Unit,
|
||||
onRememberEligible: (String) -> Unit,
|
||||
onDeclineEligible: (String) -> Unit,
|
||||
onAcceptIncoming: (String) -> Unit,
|
||||
onDeclineIncoming: (String) -> Unit,
|
||||
onAcceptOffer: (String) -> Unit,
|
||||
onDeclineOffer: (String) -> Unit,
|
||||
onSend: (String) -> Unit,
|
||||
onOpenLabel: (String) -> Unit,
|
||||
onForget: (String) -> Unit,
|
||||
onBlock: (String) -> Unit,
|
||||
onTransferAction: (String, SavedDeviceTransferAction) -> Unit,
|
||||
) {
|
||||
val hasAttention = state.attentionCount > 0
|
||||
val isEmpty = !hasAttention && state.savedDevices.isEmpty() && state.targetedTransfers.isEmpty()
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
if (state.isLoading) item(key = "loading") { androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth()) }
|
||||
if (state.loadFailed) item(key = "load-failed") { InlineLoadFailure(onRetry) }
|
||||
if (hasAttention) {
|
||||
item(key = "attention") {
|
||||
AttentionSection(
|
||||
state,
|
||||
onRememberEligible,
|
||||
onDeclineEligible,
|
||||
onAcceptIncoming,
|
||||
onDeclineIncoming,
|
||||
onAcceptOffer,
|
||||
onDeclineOffer,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.savedDevices.isNotEmpty()) {
|
||||
item(key = "saved-devices") {
|
||||
SavedDeviceSection(state, onSend, onOpenLabel, onForget, onBlock)
|
||||
}
|
||||
}
|
||||
if (state.targetedTransfers.isNotEmpty()) {
|
||||
targetedTransferItems(state.targetedTransfers, state.busyTransferIds, onTransferAction)
|
||||
}
|
||||
if (isEmpty) item(key = "empty") { SavedDevicesEmptyState(Modifier.fillParentMaxHeight(0.72f)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DesktopSavedDevicesHub(
|
||||
state: SavedDevicesState,
|
||||
onRetry: () -> Unit,
|
||||
onRememberEligible: (String) -> Unit,
|
||||
onDeclineEligible: (String) -> Unit,
|
||||
onAcceptIncoming: (String) -> Unit,
|
||||
onDeclineIncoming: (String) -> Unit,
|
||||
onAcceptOffer: (String) -> Unit,
|
||||
onDeclineOffer: (String) -> Unit,
|
||||
onSend: (String) -> Unit,
|
||||
onOpenLabel: (String) -> Unit,
|
||||
onForget: (String) -> Unit,
|
||||
onBlock: (String) -> Unit,
|
||||
onTransferAction: (String, SavedDeviceTransferAction) -> Unit,
|
||||
) {
|
||||
val hasAttention = state.attentionCount > 0
|
||||
val isEmpty = !hasAttention && state.savedDevices.isEmpty() && state.targetedTransfers.isEmpty()
|
||||
if (isEmpty && !state.isLoading && !state.loadFailed) {
|
||||
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||
SavedDevicesEmptyState(Modifier.widthIn(max = 440.dp))
|
||||
}
|
||||
return
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(32.dp),
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(0.9f).fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
) {
|
||||
if (state.isLoading) item(key = "loading") { androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth()) }
|
||||
if (state.loadFailed) item(key = "load-failed") { InlineLoadFailure(onRetry) }
|
||||
if (hasAttention) {
|
||||
item(key = "attention") {
|
||||
AttentionSection(
|
||||
state,
|
||||
onRememberEligible,
|
||||
onDeclineEligible,
|
||||
onAcceptIncoming,
|
||||
onDeclineIncoming,
|
||||
onAcceptOffer,
|
||||
onDeclineOffer,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.savedDevices.isNotEmpty()) {
|
||||
item(key = "saved-devices") {
|
||||
SavedDeviceSection(state, onSend, onOpenLabel, onForget, onBlock)
|
||||
}
|
||||
}
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1.1f).fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
) {
|
||||
if (state.targetedTransfers.isNotEmpty()) {
|
||||
targetedTransferItems(
|
||||
state.targetedTransfers,
|
||||
state.busyTransferIds,
|
||||
onTransferAction,
|
||||
presentation = TargetedTransferPresentation.DesktopRow,
|
||||
)
|
||||
} else {
|
||||
item(key = "targeted-transfer-title") { SectionTitle(stringResource(Res.string.saved_devices_transfers_title)) }
|
||||
item(key = "targeted-transfer-empty") {
|
||||
Text(
|
||||
stringResource(Res.string.saved_devices_transfer_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = LocalVniDropColors.current.foregroundLight,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val SavedDevicesState.attentionCount: Int
|
||||
get() = targetedOffers.pending.size + eligibilities.size + pendingRelationships.size
|
||||
|
||||
@Composable
|
||||
private fun AttentionSection(
|
||||
state: SavedDevicesState,
|
||||
onRememberEligible: (String) -> Unit,
|
||||
onDeclineEligible: (String) -> Unit,
|
||||
onAcceptIncoming: (String) -> Unit,
|
||||
onDeclineIncoming: (String) -> Unit,
|
||||
onAcceptOffer: (String) -> Unit,
|
||||
onDeclineOffer: (String) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
SectionTitle(stringResource(Res.string.saved_devices_attention_title))
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = LocalVniDropColors.current.backgroundSurface200,
|
||||
) {
|
||||
Column {
|
||||
var hasPrevious = false
|
||||
state.targetedOffers.pending.forEach { offer ->
|
||||
if (hasPrevious) GroupDivider()
|
||||
TargetedOfferRow(
|
||||
offer,
|
||||
state.targetedOffers.senderDisplayNames[offer.senderEndpointId],
|
||||
offer.transferId in state.targetedOffers.respondingIds,
|
||||
{ onAcceptOffer(offer.transferId) },
|
||||
{ onDeclineOffer(offer.transferId) },
|
||||
)
|
||||
hasPrevious = true
|
||||
}
|
||||
state.eligibilities.forEach { eligibility ->
|
||||
if (hasPrevious) GroupDivider()
|
||||
EligibilityRow(
|
||||
eligibility,
|
||||
eligibility.peerEndpointId in state.busyPeerIds,
|
||||
{ onRememberEligible(eligibility.peerEndpointId) },
|
||||
{ onDeclineEligible(eligibility.peerEndpointId) },
|
||||
)
|
||||
hasPrevious = true
|
||||
}
|
||||
state.pendingRelationships.forEach { relationship ->
|
||||
if (hasPrevious) GroupDivider()
|
||||
PendingPairingRow(
|
||||
relationship,
|
||||
state.eligibilities.firstOrNull { it.peerEndpointId == relationship.remoteEndpointId }?.remoteDisplayName,
|
||||
relationship.remoteEndpointId in state.busyPeerIds,
|
||||
{ onAcceptIncoming(relationship.remoteEndpointId) },
|
||||
{ onDeclineIncoming(relationship.remoteEndpointId) },
|
||||
)
|
||||
hasPrevious = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TargetedOfferRow(
|
||||
offer: PendingTargetedOfferModel,
|
||||
senderName: String?,
|
||||
busy: Boolean,
|
||||
onAccept: () -> Unit,
|
||||
onDecline: () -> Unit,
|
||||
) {
|
||||
val deviceName = senderName?.takeIf(String::isNotBlank) ?: stringResource(Res.string.saved_devices_unnamed)
|
||||
DecisionRow(
|
||||
icon = AppIcon.Download,
|
||||
title = stringResource(Res.string.offer_title),
|
||||
body = stringResource(Res.string.offer_body, deviceName, offer.transferName),
|
||||
endpointId = offer.senderEndpointId,
|
||||
busy = busy,
|
||||
) {
|
||||
PrimaryButton(stringResource(Res.string.offer_accept), onAccept, enabled = !busy)
|
||||
SecondaryButton(stringResource(Res.string.offer_decline), onDecline, enabled = !busy)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EligibilityRow(
|
||||
eligibility: PairingEligibilityModel,
|
||||
busy: Boolean,
|
||||
onRemember: () -> Unit,
|
||||
onDecline: () -> Unit,
|
||||
) {
|
||||
DecisionRow(
|
||||
icon = AppIcon.Shield,
|
||||
title = eligibility.remoteDisplayName?.takeIf(String::isNotBlank) ?: stringResource(Res.string.saved_devices_unnamed),
|
||||
body = stringResource(Res.string.saved_devices_eligibility_title),
|
||||
endpointId = eligibility.peerEndpointId,
|
||||
busy = busy,
|
||||
) {
|
||||
PrimaryButton(stringResource(Res.string.saved_devices_remember_action), onRemember, enabled = !busy)
|
||||
SecondaryButton(stringResource(Res.string.saved_devices_decline_action), onDecline, enabled = !busy)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PendingPairingRow(
|
||||
relationship: DeviceRelationshipModel,
|
||||
remoteDisplayName: String?,
|
||||
busy: Boolean,
|
||||
onAccept: () -> Unit,
|
||||
onDecline: () -> Unit,
|
||||
) {
|
||||
DecisionRow(
|
||||
icon = AppIcon.Shield,
|
||||
title = remoteDisplayName?.takeIf(String::isNotBlank) ?: stringResource(Res.string.saved_devices_unnamed),
|
||||
body = stringResource(
|
||||
if (relationship.state == DeviceRelationshipStateModel.PendingIncoming) {
|
||||
Res.string.saved_devices_pending_incoming
|
||||
} else {
|
||||
Res.string.saved_devices_pending_outgoing
|
||||
},
|
||||
),
|
||||
endpointId = relationship.remoteEndpointId,
|
||||
busy = busy,
|
||||
actions = if (relationship.state == DeviceRelationshipStateModel.PendingIncoming) {
|
||||
{
|
||||
PrimaryButton(stringResource(Res.string.saved_devices_accept_pairing_action), onAccept, enabled = !busy)
|
||||
SecondaryButton(stringResource(Res.string.saved_devices_decline_action), onDecline, enabled = !busy)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DecisionRow(
|
||||
icon: AppIcon,
|
||||
title: String,
|
||||
body: String,
|
||||
endpointId: String,
|
||||
busy: Boolean,
|
||||
actions: (@Composable RowScope.() -> Unit)?,
|
||||
) {
|
||||
val colors = LocalVniDropColors.current
|
||||
Column(Modifier.padding(horizontal = 16.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
PlatformIcon(icon, contentDescription = null, tint = colors.brandLink, modifier = Modifier.padding(top = 2.dp).size(22.dp))
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||
Text(body, style = MaterialTheme.typography.bodyMedium, color = colors.foregroundLight)
|
||||
DiagnosticEndpoint(endpointId)
|
||||
}
|
||||
if (busy) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
}
|
||||
if (actions != null) Row(horizontalArrangement = Arrangement.spacedBy(8.dp), content = actions)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SavedDeviceSection(
|
||||
state: SavedDevicesState,
|
||||
onSend: (String) -> Unit,
|
||||
onOpenLabel: (String) -> Unit,
|
||||
onForget: (String) -> Unit,
|
||||
onBlock: (String) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
SectionTitle(stringResource(Res.string.saved_devices_devices_title))
|
||||
Surface(shape = RoundedCornerShape(12.dp), color = LocalVniDropColors.current.backgroundSurface200) {
|
||||
Column {
|
||||
state.savedDevices.forEachIndexed { index, device ->
|
||||
if (index > 0) GroupDivider()
|
||||
SavedDeviceRow(
|
||||
device = device,
|
||||
busy = device.endpointId in state.busyPeerIds,
|
||||
onSend = { onSend(device.endpointId) },
|
||||
onLabel = { onOpenLabel(device.endpointId) },
|
||||
onForget = { onForget(device.endpointId) },
|
||||
onBlock = { onBlock(device.endpointId) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class DeviceDestructiveAction { Forget, Block }
|
||||
|
||||
@Composable
|
||||
private fun SavedDeviceRow(
|
||||
device: SavedDeviceModel,
|
||||
busy: Boolean,
|
||||
onSend: () -> Unit,
|
||||
onLabel: () -> Unit,
|
||||
onForget: () -> Unit,
|
||||
onBlock: () -> Unit,
|
||||
) {
|
||||
val colors = LocalVniDropColors.current
|
||||
val title = device.displayName()
|
||||
var menuExpanded by remember(device.endpointId) { mutableStateOf(false) }
|
||||
var pendingAction by remember(device.endpointId) { mutableStateOf<DeviceDestructiveAction?>(null) }
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().testTag("saved-device-${device.endpointId}").padding(start = 16.dp, end = 6.dp, top = 12.dp, bottom = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
PlatformIcon(AppIcon.ShieldCheck, contentDescription = null, tint = colors.foregroundLight, modifier = Modifier.size(24.dp))
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
device.remoteDisplayName
|
||||
?.takeIf { device.localLabel?.isNotBlank() == true && it.isNotBlank() }
|
||||
?.let { Text(stringResource(Res.string.saved_devices_authenticated_name, it), style = MaterialTheme.typography.bodySmall, color = colors.foregroundLight) }
|
||||
DiagnosticEndpoint(device.endpointId)
|
||||
}
|
||||
if (busy) {
|
||||
CircularProgressIndicator(Modifier.padding(horizontal = 14.dp).size(20.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
val sendLabel = stringResource(Res.string.saved_devices_send_action)
|
||||
IconButton(onClick = onSend) {
|
||||
PlatformIcon(AppIcon.Send, contentDescription = sendLabel)
|
||||
}
|
||||
Box {
|
||||
val moreLabel = stringResource(Res.string.saved_devices_more_actions, title)
|
||||
IconButton(onClick = { menuExpanded = true }) {
|
||||
PlatformIcon(AppIcon.MoreVertical, contentDescription = moreLabel)
|
||||
}
|
||||
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.saved_devices_label_action)) },
|
||||
onClick = { menuExpanded = false; onLabel() },
|
||||
leadingIcon = { PlatformIcon(AppIcon.User, contentDescription = null) },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.saved_devices_forget_action)) },
|
||||
onClick = { menuExpanded = false; pendingAction = DeviceDestructiveAction.Forget },
|
||||
leadingIcon = { PlatformIcon(AppIcon.UserOff, contentDescription = null) },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.saved_devices_block_action)) },
|
||||
onClick = { menuExpanded = false; pendingAction = DeviceDestructiveAction.Block },
|
||||
leadingIcon = { PlatformIcon(AppIcon.Lock, contentDescription = null) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pendingAction?.let { action ->
|
||||
val isBlock = action == DeviceDestructiveAction.Block
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingAction = null },
|
||||
title = { Text(stringResource(if (isBlock) Res.string.saved_devices_block_confirm_title else Res.string.saved_devices_forget_confirm_title)) },
|
||||
text = {
|
||||
Text(stringResource(if (isBlock) Res.string.saved_devices_block_confirm_body else Res.string.saved_devices_forget_confirm_body, title))
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { pendingAction = null; if (isBlock) onBlock() else onForget() }) {
|
||||
Text(stringResource(if (isBlock) Res.string.saved_devices_block_action else Res.string.saved_devices_forget_action))
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { pendingAction = null }) { Text(stringResource(Res.string.button_cancel)) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionTitle(title: String) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.semantics { heading() },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupDivider() {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = 54.dp),
|
||||
color = LocalVniDropColors.current.borderDefault.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiagnosticEndpoint(endpointId: String) {
|
||||
Text(
|
||||
stringResource(Res.string.saved_devices_endpoint, shortEndpoint(endpointId)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = LocalVniDropColors.current.foregroundLighter,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SavedDevicesEmptyState(modifier: Modifier = Modifier) {
|
||||
val colors = LocalVniDropColors.current
|
||||
Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
PlatformIcon(AppIcon.ShieldCheck, contentDescription = null, tint = colors.foregroundLighter, modifier = Modifier.size(36.dp))
|
||||
Text(stringResource(Res.string.saved_devices_empty_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
stringResource(Res.string.saved_devices_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = colors.foregroundLight,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InlineLoadFailure(onRetry: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(stringResource(Res.string.saved_devices_load_failed), Modifier.weight(1f), color = LocalVniDropColors.current.foregroundLight)
|
||||
TextButton(onClick = onRetry) { Text(stringResource(Res.string.button_retry)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SavedDeviceModel.displayName(): String = localLabel?.takeIf(String::isNotBlank)
|
||||
?: remoteDisplayName?.takeIf(String::isNotBlank)
|
||||
?: stringResource(Res.string.saved_devices_unnamed)
|
||||
|
||||
private fun shortEndpoint(endpointId: String): String =
|
||||
if (endpointId.length <= 20) endpointId else endpointId.take(16) + "…"
|
||||
@@ -0,0 +1,287 @@
|
||||
package com.vnidrop.app.feature.saveddevices
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vnidrop.app.core.TargetedTransferStateModel
|
||||
import com.vnidrop.app.ui.components.PrimaryButton
|
||||
import com.vnidrop.app.ui.components.SecondaryButton
|
||||
import com.vnidrop.app.ui.icons.AppIcon
|
||||
import com.vnidrop.app.ui.icons.PlatformIcon
|
||||
import com.vnidrop.app.ui.state.formatBytes
|
||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.saved_devices_transfer_cancel
|
||||
import vnidrop.shared.generated.resources.saved_devices_transfer_delete
|
||||
import vnidrop.shared.generated.resources.saved_devices_transfer_direction_incoming
|
||||
import vnidrop.shared.generated.resources.saved_devices_transfer_direction_outgoing
|
||||
import vnidrop.shared.generated.resources.saved_devices_transfer_files
|
||||
import vnidrop.shared.generated.resources.saved_devices_transfer_progress
|
||||
import vnidrop.shared.generated.resources.saved_devices_transfer_receive
|
||||
import vnidrop.shared.generated.resources.saved_devices_transfer_resume
|
||||
import vnidrop.shared.generated.resources.saved_devices_transfers_title
|
||||
import vnidrop.shared.generated.resources.saved_devices_endpoint
|
||||
import vnidrop.shared.generated.resources.saved_devices_unnamed
|
||||
import vnidrop.shared.generated.resources.status_approved
|
||||
import vnidrop.shared.generated.resources.status_awaiting_approval
|
||||
import vnidrop.shared.generated.resources.status_cancelled
|
||||
import vnidrop.shared.generated.resources.status_completed
|
||||
import vnidrop.shared.generated.resources.status_connecting
|
||||
import vnidrop.shared.generated.resources.status_declined
|
||||
import vnidrop.shared.generated.resources.status_failed
|
||||
import vnidrop.shared.generated.resources.status_interrupted
|
||||
import vnidrop.shared.generated.resources.status_offering
|
||||
import vnidrop.shared.generated.resources.status_preparing
|
||||
import vnidrop.shared.generated.resources.status_transferring
|
||||
|
||||
enum class SavedDeviceTransferAction {
|
||||
Receive,
|
||||
Resume,
|
||||
Cancel,
|
||||
Delete,
|
||||
}
|
||||
|
||||
internal enum class TargetedTransferPresentation {
|
||||
CompactRow,
|
||||
DesktopRow,
|
||||
}
|
||||
|
||||
internal fun LazyListScope.targetedTransferItems(
|
||||
transfers: List<SavedDeviceTransferItem>,
|
||||
busyTransferIds: Set<String>,
|
||||
onAction: (String, SavedDeviceTransferAction) -> Unit,
|
||||
presentation: TargetedTransferPresentation = TargetedTransferPresentation.CompactRow,
|
||||
) {
|
||||
item(key = "targeted-transfer-title") {
|
||||
Text(
|
||||
text = stringResource(Res.string.saved_devices_transfers_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(top = 8.dp).semantics { heading() },
|
||||
)
|
||||
}
|
||||
if (transfers.isNotEmpty()) {
|
||||
itemsIndexed(transfers, key = { _, transfer -> "targeted-${transfer.id}" }) { index, transfer ->
|
||||
if (index > 0) {
|
||||
HorizontalDivider(color = LocalVniDropColors.current.borderDefault.copy(alpha = 0.7f))
|
||||
}
|
||||
TargetedTransferRow(
|
||||
transfer = transfer,
|
||||
busy = transfer.id in busyTransferIds,
|
||||
presentation = presentation,
|
||||
onAction = { action -> onAction(transfer.id, action) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TargetedTransferRow(
|
||||
transfer: SavedDeviceTransferItem,
|
||||
busy: Boolean,
|
||||
presentation: TargetedTransferPresentation,
|
||||
onAction: (SavedDeviceTransferAction) -> Unit,
|
||||
) {
|
||||
val colors = LocalVniDropColors.current
|
||||
val progress = if (transfer.totalSize == 0UL) 0f else {
|
||||
(transfer.verifiedBytes.toDouble() / transfer.totalSize.toDouble()).coerceIn(0.0, 1.0).toFloat()
|
||||
}
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(
|
||||
horizontal = if (presentation == TargetedTransferPresentation.DesktopRow) 4.dp else 0.dp,
|
||||
vertical = 14.dp,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
PlatformIcon(
|
||||
if (transfer.direction == SavedDeviceTransferDirection.Incoming) AppIcon.Download else AppIcon.Send,
|
||||
contentDescription = null,
|
||||
tint = colors.foregroundLight,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
transfer.transferName.ifBlank { stringResource(Res.string.saved_devices_unnamed) },
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
stringResource(
|
||||
if (transfer.direction == SavedDeviceTransferDirection.Incoming) {
|
||||
Res.string.saved_devices_transfer_direction_incoming
|
||||
} else {
|
||||
Res.string.saved_devices_transfer_direction_outgoing
|
||||
},
|
||||
transfer.peerDisplayName?.takeIf(String::isNotBlank)
|
||||
?: stringResource(Res.string.saved_devices_unnamed),
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = colors.foregroundLight,
|
||||
)
|
||||
}
|
||||
if (busy) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "${stringResource(transfer.state.labelResource())} · " +
|
||||
stringResource(
|
||||
Res.string.saved_devices_transfer_files,
|
||||
transfer.fileCount.toString(),
|
||||
formatBytes(transfer.totalSize),
|
||||
),
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = colors.foregroundLighter,
|
||||
)
|
||||
if (presentation == TargetedTransferPresentation.DesktopRow) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
Res.string.saved_devices_endpoint,
|
||||
shortTransferEndpoint(transfer.peerEndpointId),
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = colors.foregroundLighter,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (presentation == TargetedTransferPresentation.CompactRow) {
|
||||
Text(
|
||||
text = stringResource(Res.string.saved_devices_endpoint, shortTransferEndpoint(transfer.peerEndpointId)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = colors.foregroundLighter,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (
|
||||
transfer.direction == SavedDeviceTransferDirection.Incoming &&
|
||||
transfer.totalSize > 0UL &&
|
||||
transfer.state.showsProgress()
|
||||
) {
|
||||
LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
|
||||
Text(
|
||||
stringResource(
|
||||
Res.string.saved_devices_transfer_progress,
|
||||
formatBytes(transfer.verifiedBytes),
|
||||
formatBytes(transfer.totalSize),
|
||||
),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colors.foregroundLighter,
|
||||
)
|
||||
}
|
||||
TransferActions(transfer, busy, onAction)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TransferActions(
|
||||
transfer: SavedDeviceTransferItem,
|
||||
busy: Boolean,
|
||||
onAction: (SavedDeviceTransferAction) -> Unit,
|
||||
) {
|
||||
val incoming = transfer.direction == SavedDeviceTransferDirection.Incoming
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
when {
|
||||
incoming && transfer.state == TargetedTransferStateModel.Approved -> PrimaryButton(
|
||||
stringResource(Res.string.saved_devices_transfer_receive),
|
||||
{ onAction(SavedDeviceTransferAction.Receive) },
|
||||
enabled = !busy,
|
||||
leadingIcon = { PlatformIcon(AppIcon.Download, contentDescription = null, modifier = Modifier.size(18.dp)) },
|
||||
)
|
||||
incoming && transfer.state == TargetedTransferStateModel.Interrupted -> PrimaryButton(
|
||||
stringResource(Res.string.saved_devices_transfer_resume),
|
||||
{ onAction(SavedDeviceTransferAction.Resume) },
|
||||
enabled = !busy,
|
||||
leadingIcon = { PlatformIcon(AppIcon.Sync, contentDescription = null, modifier = Modifier.size(18.dp)) },
|
||||
)
|
||||
}
|
||||
when {
|
||||
transfer.state.isCancellable() -> SecondaryButton(
|
||||
stringResource(Res.string.saved_devices_transfer_cancel),
|
||||
{ onAction(SavedDeviceTransferAction.Cancel) },
|
||||
enabled = !busy,
|
||||
leadingIcon = { PlatformIcon(AppIcon.StopCircle, contentDescription = null, modifier = Modifier.size(18.dp)) },
|
||||
)
|
||||
transfer.state.isDeletable() -> SecondaryButton(
|
||||
stringResource(Res.string.saved_devices_transfer_delete),
|
||||
{ onAction(SavedDeviceTransferAction.Delete) },
|
||||
enabled = !busy,
|
||||
leadingIcon = { PlatformIcon(AppIcon.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun TargetedTransferStateModel.labelResource(): StringResource = when (this) {
|
||||
TargetedTransferStateModel.Preparing -> Res.string.status_preparing
|
||||
TargetedTransferStateModel.Offering -> Res.string.status_offering
|
||||
TargetedTransferStateModel.AwaitingApproval -> Res.string.status_awaiting_approval
|
||||
TargetedTransferStateModel.Approved -> Res.string.status_approved
|
||||
TargetedTransferStateModel.Connecting -> Res.string.status_connecting
|
||||
TargetedTransferStateModel.Transferring -> Res.string.status_transferring
|
||||
TargetedTransferStateModel.Interrupted -> Res.string.status_interrupted
|
||||
TargetedTransferStateModel.Completed -> Res.string.status_completed
|
||||
TargetedTransferStateModel.Declined -> Res.string.status_declined
|
||||
TargetedTransferStateModel.Cancelled -> Res.string.status_cancelled
|
||||
TargetedTransferStateModel.Failed -> Res.string.status_failed
|
||||
TargetedTransferStateModel.Deleted -> Res.string.status_cancelled
|
||||
}
|
||||
|
||||
private fun TargetedTransferStateModel.showsProgress(): Boolean = this in setOf(
|
||||
TargetedTransferStateModel.Connecting,
|
||||
TargetedTransferStateModel.Transferring,
|
||||
TargetedTransferStateModel.Interrupted,
|
||||
TargetedTransferStateModel.Completed,
|
||||
)
|
||||
|
||||
private fun TargetedTransferStateModel.isCancellable(): Boolean = this in setOf(
|
||||
TargetedTransferStateModel.Preparing,
|
||||
TargetedTransferStateModel.Offering,
|
||||
TargetedTransferStateModel.AwaitingApproval,
|
||||
TargetedTransferStateModel.Approved,
|
||||
TargetedTransferStateModel.Connecting,
|
||||
TargetedTransferStateModel.Transferring,
|
||||
TargetedTransferStateModel.Interrupted,
|
||||
)
|
||||
|
||||
private fun TargetedTransferStateModel.isDeletable(): Boolean = this in setOf(
|
||||
TargetedTransferStateModel.Completed,
|
||||
TargetedTransferStateModel.Declined,
|
||||
TargetedTransferStateModel.Cancelled,
|
||||
TargetedTransferStateModel.Failed,
|
||||
)
|
||||
|
||||
private fun shortTransferEndpoint(endpointId: String): String =
|
||||
if (endpointId.length <= 20) endpointId else endpointId.take(16) + "…"
|
||||
@@ -25,6 +25,8 @@ internal fun SavedDevicesRoute(
|
||||
onDeclineEligible = viewModel::declineEligible,
|
||||
onAcceptIncoming = viewModel::acceptIncoming,
|
||||
onDeclineIncoming = viewModel::declineIncoming,
|
||||
onAcceptOffer = viewModel::acceptTargetedOffer,
|
||||
onDeclineOffer = viewModel::declineTargetedOffer,
|
||||
onSend = { peerEndpointId ->
|
||||
state.savedDevices.firstOrNull { it.endpointId == peerEndpointId }
|
||||
?.let { targetedDraftViewModel.openTargeted(it, unnamedDeviceName) }
|
||||
@@ -32,6 +34,14 @@ internal fun SavedDevicesRoute(
|
||||
onOpenLabel = viewModel::openLabelEditor,
|
||||
onForget = viewModel::forget,
|
||||
onBlock = viewModel::block,
|
||||
onTransferAction = { transferId, action ->
|
||||
when (action) {
|
||||
SavedDeviceTransferAction.Receive -> viewModel.receiveTargetedTransfer(transferId)
|
||||
SavedDeviceTransferAction.Resume -> viewModel.resumeTargetedTransfer(transferId)
|
||||
SavedDeviceTransferAction.Cancel -> viewModel.cancelTargetedTransfer(transferId)
|
||||
SavedDeviceTransferAction.Delete -> viewModel.deleteTargetedTransfer(transferId)
|
||||
}
|
||||
},
|
||||
onLabelDraftChanged = viewModel::setLabelDraft,
|
||||
onSaveLabel = viewModel::saveLabel,
|
||||
onClearLabel = viewModel::clearLabel,
|
||||
|
||||
@@ -1,52 +1,28 @@
|
||||
package com.vnidrop.app.feature.saveddevices
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vnidrop.app.core.DeviceRelationshipModel
|
||||
import com.vnidrop.app.core.DeviceRelationshipStateModel
|
||||
import com.vnidrop.app.core.PairingEligibilityModel
|
||||
import com.vnidrop.app.core.SavedDeviceModel
|
||||
import com.vnidrop.app.ui.components.PrimaryButton
|
||||
import com.vnidrop.app.ui.components.SecondaryButton
|
||||
import com.vnidrop.app.ui.icons.AppIcon
|
||||
import com.vnidrop.app.ui.icons.PlatformIcon
|
||||
@@ -56,20 +32,7 @@ import org.jetbrains.compose.resources.stringResource
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.button_cancel
|
||||
import vnidrop.shared.generated.resources.button_retry
|
||||
import vnidrop.shared.generated.resources.saved_devices_accept_pairing_action
|
||||
import vnidrop.shared.generated.resources.saved_devices_authenticated_name
|
||||
import vnidrop.shared.generated.resources.saved_devices_block_action
|
||||
import vnidrop.shared.generated.resources.saved_devices_block_confirm_body
|
||||
import vnidrop.shared.generated.resources.saved_devices_block_confirm_title
|
||||
import vnidrop.shared.generated.resources.saved_devices_decline_action
|
||||
import vnidrop.shared.generated.resources.saved_devices_description
|
||||
import vnidrop.shared.generated.resources.saved_devices_eligibility_title
|
||||
import vnidrop.shared.generated.resources.saved_devices_empty
|
||||
import vnidrop.shared.generated.resources.saved_devices_endpoint
|
||||
import vnidrop.shared.generated.resources.saved_devices_forget_action
|
||||
import vnidrop.shared.generated.resources.saved_devices_forget_confirm_body
|
||||
import vnidrop.shared.generated.resources.saved_devices_forget_confirm_title
|
||||
import vnidrop.shared.generated.resources.saved_devices_label_action
|
||||
import vnidrop.shared.generated.resources.saved_devices_label_clear
|
||||
import vnidrop.shared.generated.resources.saved_devices_label_placeholder
|
||||
import vnidrop.shared.generated.resources.saved_devices_label_save
|
||||
@@ -77,14 +40,6 @@ import vnidrop.shared.generated.resources.saved_devices_label_title
|
||||
import vnidrop.shared.generated.resources.saved_devices_list_title
|
||||
import vnidrop.shared.generated.resources.saved_devices_load_failed
|
||||
import vnidrop.shared.generated.resources.saved_devices_loading
|
||||
import vnidrop.shared.generated.resources.saved_devices_more_actions
|
||||
import vnidrop.shared.generated.resources.saved_devices_no_pending
|
||||
import vnidrop.shared.generated.resources.saved_devices_pending_incoming
|
||||
import vnidrop.shared.generated.resources.saved_devices_pending_outgoing
|
||||
import vnidrop.shared.generated.resources.saved_devices_pending_title
|
||||
import vnidrop.shared.generated.resources.saved_devices_remember_action
|
||||
import vnidrop.shared.generated.resources.saved_devices_send_action
|
||||
import vnidrop.shared.generated.resources.saved_devices_unnamed
|
||||
|
||||
@Composable
|
||||
internal fun SavedDevicesScreen(
|
||||
@@ -96,50 +51,58 @@ internal fun SavedDevicesScreen(
|
||||
onDeclineEligible: (String) -> Unit,
|
||||
onAcceptIncoming: (String) -> Unit,
|
||||
onDeclineIncoming: (String) -> Unit,
|
||||
onAcceptOffer: (String) -> Unit,
|
||||
onDeclineOffer: (String) -> Unit,
|
||||
onSend: (String) -> Unit,
|
||||
onOpenLabel: (String) -> Unit,
|
||||
onForget: (String) -> Unit,
|
||||
onBlock: (String) -> Unit,
|
||||
onTransferAction: (String, SavedDeviceTransferAction) -> Unit,
|
||||
onLabelDraftChanged: (String) -> Unit,
|
||||
onSaveLabel: () -> Unit,
|
||||
onClearLabel: () -> Unit,
|
||||
onDismissLabel: () -> Unit,
|
||||
) {
|
||||
val hasContent = state.eligibilities.isNotEmpty() || state.pendingRelationships.isNotEmpty() || state.savedDevices.isNotEmpty()
|
||||
val hasContent = state.eligibilities.isNotEmpty() || state.pendingRelationships.isNotEmpty() ||
|
||||
state.savedDevices.isNotEmpty() || state.targetedTransfers.isNotEmpty() ||
|
||||
state.targetedOffers.pending.isNotEmpty()
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.statusBarsPadding()
|
||||
.padding(top = 20.dp),
|
||||
modifier = modifier.fillMaxSize().statusBarsPadding().padding(top = 16.dp),
|
||||
) {
|
||||
SavedDevicesHeader(Modifier.padding(horizontal = if (windowClass == WindowClass.Desktop) 24.dp else 16.dp))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Spacer(Modifier.height(20.dp))
|
||||
when {
|
||||
state.isLoading && !hasContent -> SavedDevicesLoading(Modifier.weight(1f))
|
||||
state.loadFailed && !hasContent -> SavedDevicesLoadFailure(onRetry, Modifier.weight(1f))
|
||||
windowClass == WindowClass.Desktop -> DesktopSavedDevicesContent(
|
||||
windowClass == WindowClass.Desktop -> DesktopSavedDevicesHub(
|
||||
state = state,
|
||||
onRetry = onRetry,
|
||||
onRememberEligible = onRememberEligible,
|
||||
onDeclineEligible = onDeclineEligible,
|
||||
onAcceptIncoming = onAcceptIncoming,
|
||||
onDeclineIncoming = onDeclineIncoming,
|
||||
onAcceptOffer = onAcceptOffer,
|
||||
onDeclineOffer = onDeclineOffer,
|
||||
onSend = onSend,
|
||||
onOpenLabel = onOpenLabel,
|
||||
onForget = onForget,
|
||||
onBlock = onBlock,
|
||||
onTransferAction = onTransferAction,
|
||||
)
|
||||
else -> CompactSavedDevicesContent(
|
||||
else -> CompactSavedDevicesHub(
|
||||
state = state,
|
||||
onRetry = onRetry,
|
||||
onRememberEligible = onRememberEligible,
|
||||
onDeclineEligible = onDeclineEligible,
|
||||
onAcceptIncoming = onAcceptIncoming,
|
||||
onDeclineIncoming = onDeclineIncoming,
|
||||
onAcceptOffer = onAcceptOffer,
|
||||
onDeclineOffer = onDeclineOffer,
|
||||
onSend = onSend,
|
||||
onOpenLabel = onOpenLabel,
|
||||
onForget = onForget,
|
||||
onBlock = onBlock,
|
||||
onTransferAction = onTransferAction,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -155,436 +118,21 @@ internal fun SavedDevicesScreen(
|
||||
|
||||
@Composable
|
||||
private fun SavedDevicesHeader(modifier: Modifier = Modifier) {
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = stringResource(Res.string.saved_devices_list_title),
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.semantics { heading() },
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.saved_devices_description),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = LocalVniDropColors.current.foregroundLight,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompactSavedDevicesContent(
|
||||
state: SavedDevicesState,
|
||||
onRetry: () -> Unit,
|
||||
onRememberEligible: (String) -> Unit,
|
||||
onDeclineEligible: (String) -> Unit,
|
||||
onAcceptIncoming: (String) -> Unit,
|
||||
onDeclineIncoming: (String) -> Unit,
|
||||
onSend: (String) -> Unit,
|
||||
onOpenLabel: (String) -> Unit,
|
||||
onForget: (String) -> Unit,
|
||||
onBlock: (String) -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (state.isLoading) item(key = "loading") { LinearProgressIndicator(Modifier.fillMaxWidth()) }
|
||||
if (state.loadFailed) item(key = "load-failed") { InlineLoadFailure(onRetry) }
|
||||
pairingItems(
|
||||
state = state,
|
||||
onRememberEligible = onRememberEligible,
|
||||
onDeclineEligible = onDeclineEligible,
|
||||
onAcceptIncoming = onAcceptIncoming,
|
||||
onDeclineIncoming = onDeclineIncoming,
|
||||
)
|
||||
item(key = "saved-title") { SectionTitle(stringResource(Res.string.saved_devices_list_title)) }
|
||||
if (state.savedDevices.isEmpty()) {
|
||||
item(key = "empty") { SavedDevicesEmptyCard() }
|
||||
} else {
|
||||
items(state.savedDevices, key = { "saved-${it.endpointId}" }) { device ->
|
||||
SavedDeviceCard(
|
||||
device = device,
|
||||
busy = device.endpointId in state.busyPeerIds,
|
||||
onSend = { onSend(device.endpointId) },
|
||||
onLabel = { onOpenLabel(device.endpointId) },
|
||||
onForget = { onForget(device.endpointId) },
|
||||
onBlock = { onBlock(device.endpointId) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopSavedDevicesContent(
|
||||
state: SavedDevicesState,
|
||||
onRetry: () -> Unit,
|
||||
onRememberEligible: (String) -> Unit,
|
||||
onDeclineEligible: (String) -> Unit,
|
||||
onAcceptIncoming: (String) -> Unit,
|
||||
onDeclineIncoming: (String) -> Unit,
|
||||
onSend: (String) -> Unit,
|
||||
onOpenLabel: (String) -> Unit,
|
||||
onForget: (String) -> Unit,
|
||||
onBlock: (String) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(0.8f).fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 24.dp),
|
||||
) {
|
||||
if (state.isLoading) item(key = "loading") { LinearProgressIndicator(Modifier.fillMaxWidth()) }
|
||||
if (state.loadFailed) item(key = "load-failed") { InlineLoadFailure(onRetry) }
|
||||
pairingItems(
|
||||
state = state,
|
||||
onRememberEligible = onRememberEligible,
|
||||
onDeclineEligible = onDeclineEligible,
|
||||
onAcceptIncoming = onAcceptIncoming,
|
||||
onDeclineIncoming = onDeclineIncoming,
|
||||
)
|
||||
if (state.eligibilities.isEmpty() && state.pendingRelationships.isEmpty()) {
|
||||
item(key = "no-attention") {
|
||||
DesktopStatusCard()
|
||||
}
|
||||
}
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1.2f).fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 24.dp),
|
||||
) {
|
||||
item(key = "saved-title") { SectionTitle(stringResource(Res.string.saved_devices_list_title)) }
|
||||
if (state.savedDevices.isEmpty()) {
|
||||
item(key = "empty") { SavedDevicesEmptyCard() }
|
||||
} else {
|
||||
items(state.savedDevices, key = { "saved-${it.endpointId}" }) { device ->
|
||||
SavedDeviceCard(
|
||||
device = device,
|
||||
busy = device.endpointId in state.busyPeerIds,
|
||||
onSend = { onSend(device.endpointId) },
|
||||
onLabel = { onOpenLabel(device.endpointId) },
|
||||
onForget = { onForget(device.endpointId) },
|
||||
onBlock = { onBlock(device.endpointId) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun androidx.compose.foundation.lazy.LazyListScope.pairingItems(
|
||||
state: SavedDevicesState,
|
||||
onRememberEligible: (String) -> Unit,
|
||||
onDeclineEligible: (String) -> Unit,
|
||||
onAcceptIncoming: (String) -> Unit,
|
||||
onDeclineIncoming: (String) -> Unit,
|
||||
) {
|
||||
if (state.eligibilities.isNotEmpty()) {
|
||||
item(key = "eligibility-title") {
|
||||
SectionTitle(stringResource(Res.string.saved_devices_eligibility_title))
|
||||
}
|
||||
items(state.eligibilities, key = { "eligibility-${it.peerEndpointId}" }) { eligibility ->
|
||||
EligibilityCard(
|
||||
eligibility = eligibility,
|
||||
busy = eligibility.peerEndpointId in state.busyPeerIds,
|
||||
onRemember = { onRememberEligible(eligibility.peerEndpointId) },
|
||||
onDecline = { onDeclineEligible(eligibility.peerEndpointId) },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.pendingRelationships.isNotEmpty()) {
|
||||
item(key = "pending-title") {
|
||||
SectionTitle(stringResource(Res.string.saved_devices_pending_title))
|
||||
}
|
||||
items(state.pendingRelationships, key = { "pending-${it.remoteEndpointId}" }) { relationship ->
|
||||
PendingPairingCard(
|
||||
relationship = relationship,
|
||||
remoteDisplayName = state.eligibilities
|
||||
.firstOrNull { it.peerEndpointId == relationship.remoteEndpointId }
|
||||
?.remoteDisplayName,
|
||||
busy = relationship.remoteEndpointId in state.busyPeerIds,
|
||||
onAccept = { onAcceptIncoming(relationship.remoteEndpointId) },
|
||||
onDecline = { onDeclineIncoming(relationship.remoteEndpointId) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionTitle(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(top = 4.dp).semantics { heading() },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EligibilityCard(
|
||||
eligibility: PairingEligibilityModel,
|
||||
busy: Boolean,
|
||||
onRemember: () -> Unit,
|
||||
onDecline: () -> Unit,
|
||||
) {
|
||||
PairingCard(
|
||||
name = eligibility.remoteDisplayName,
|
||||
endpointId = eligibility.peerEndpointId,
|
||||
status = stringResource(Res.string.saved_devices_eligibility_title),
|
||||
busy = busy,
|
||||
actions = {
|
||||
PrimaryButton(stringResource(Res.string.saved_devices_remember_action), onRemember, enabled = !busy)
|
||||
SecondaryButton(stringResource(Res.string.saved_devices_decline_action), onDecline, enabled = !busy)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PendingPairingCard(
|
||||
relationship: DeviceRelationshipModel,
|
||||
remoteDisplayName: String?,
|
||||
busy: Boolean,
|
||||
onAccept: () -> Unit,
|
||||
onDecline: () -> Unit,
|
||||
) {
|
||||
PairingCard(
|
||||
name = remoteDisplayName,
|
||||
endpointId = relationship.remoteEndpointId,
|
||||
status = stringResource(
|
||||
when (relationship.state) {
|
||||
DeviceRelationshipStateModel.PendingIncoming -> Res.string.saved_devices_pending_incoming
|
||||
else -> Res.string.saved_devices_pending_outgoing
|
||||
},
|
||||
),
|
||||
busy = busy,
|
||||
actions = if (relationship.state == DeviceRelationshipStateModel.PendingIncoming) {
|
||||
{
|
||||
PrimaryButton(stringResource(Res.string.saved_devices_accept_pairing_action), onAccept, enabled = !busy)
|
||||
SecondaryButton(stringResource(Res.string.saved_devices_decline_action), onDecline, enabled = !busy)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PairingCard(
|
||||
name: String?,
|
||||
endpointId: String,
|
||||
status: String,
|
||||
busy: Boolean,
|
||||
actions: (@Composable RowScope.() -> Unit)?,
|
||||
) {
|
||||
val colors = LocalVniDropColors.current
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface200),
|
||||
border = BorderStroke(1.dp, colors.borderDefault.copy(alpha = 0.72f)),
|
||||
) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
PlatformIcon(AppIcon.Shield, contentDescription = null, tint = colors.brandLink, modifier = Modifier.size(24.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
name?.takeIf(String::isNotBlank) ?: stringResource(Res.string.saved_devices_unnamed),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(status, style = MaterialTheme.typography.bodyMedium, color = colors.foregroundLight)
|
||||
}
|
||||
if (busy) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
}
|
||||
DiagnosticEndpoint(endpointId)
|
||||
if (actions != null) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), content = actions)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class DeviceDestructiveAction { Forget, Block }
|
||||
|
||||
@Composable
|
||||
private fun SavedDeviceCard(
|
||||
device: SavedDeviceModel,
|
||||
busy: Boolean,
|
||||
onSend: () -> Unit,
|
||||
onLabel: () -> Unit,
|
||||
onForget: () -> Unit,
|
||||
onBlock: () -> Unit,
|
||||
) {
|
||||
val colors = LocalVniDropColors.current
|
||||
val title = device.displayName()
|
||||
var menuExpanded by remember(device.endpointId) { mutableStateOf(false) }
|
||||
var pendingAction by remember(device.endpointId) { mutableStateOf<DeviceDestructiveAction?>(null) }
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface200),
|
||||
border = BorderStroke(1.dp, colors.borderDefault.copy(alpha = 0.72f)),
|
||||
) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = colors.backgroundSelection),
|
||||
) {
|
||||
PlatformIcon(
|
||||
AppIcon.ShieldCheck,
|
||||
contentDescription = null,
|
||||
tint = colors.brandLink,
|
||||
modifier = Modifier.padding(10.dp).size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
device.remoteDisplayName
|
||||
?.takeIf { device.localLabel?.isNotBlank() == true && it.isNotBlank() }
|
||||
?.let { authenticatedName ->
|
||||
Text(
|
||||
stringResource(Res.string.saved_devices_authenticated_name, authenticatedName),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = colors.foregroundLight,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (busy) {
|
||||
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Box {
|
||||
val moreLabel = stringResource(Res.string.saved_devices_more_actions, title)
|
||||
IconButton(onClick = { menuExpanded = true }) {
|
||||
PlatformIcon(AppIcon.MoreVertical, contentDescription = moreLabel)
|
||||
}
|
||||
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.saved_devices_label_action)) },
|
||||
onClick = { menuExpanded = false; onLabel() },
|
||||
leadingIcon = { PlatformIcon(AppIcon.User, contentDescription = null) },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.saved_devices_forget_action)) },
|
||||
onClick = { menuExpanded = false; pendingAction = DeviceDestructiveAction.Forget },
|
||||
leadingIcon = { PlatformIcon(AppIcon.UserOff, contentDescription = null) },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.saved_devices_block_action)) },
|
||||
onClick = { menuExpanded = false; pendingAction = DeviceDestructiveAction.Block },
|
||||
leadingIcon = { PlatformIcon(AppIcon.Lock, contentDescription = null) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
DiagnosticEndpoint(device.endpointId)
|
||||
PrimaryButton(
|
||||
text = stringResource(Res.string.saved_devices_send_action),
|
||||
onClick = onSend,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !busy,
|
||||
leadingIcon = { PlatformIcon(AppIcon.Send, contentDescription = null, modifier = Modifier.size(18.dp)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
pendingAction?.let { action ->
|
||||
val isBlock = action == DeviceDestructiveAction.Block
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingAction = null },
|
||||
title = {
|
||||
Text(stringResource(if (isBlock) Res.string.saved_devices_block_confirm_title else Res.string.saved_devices_forget_confirm_title))
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
stringResource(
|
||||
if (isBlock) Res.string.saved_devices_block_confirm_body else Res.string.saved_devices_forget_confirm_body,
|
||||
title,
|
||||
),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
pendingAction = null
|
||||
if (isBlock) onBlock() else onForget()
|
||||
},
|
||||
) {
|
||||
Text(stringResource(if (isBlock) Res.string.saved_devices_block_action else Res.string.saved_devices_forget_action))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { pendingAction = null }) {
|
||||
Text(stringResource(Res.string.button_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiagnosticEndpoint(endpointId: String) {
|
||||
Text(
|
||||
text = stringResource(Res.string.saved_devices_endpoint, shortEndpoint(endpointId)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = LocalVniDropColors.current.foregroundLighter,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SavedDevicesEmptyCard() {
|
||||
val colors = LocalVniDropColors.current
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface200),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
PlatformIcon(AppIcon.ShieldCheck, contentDescription = null, tint = colors.foregroundLighter, modifier = Modifier.size(32.dp))
|
||||
Text(
|
||||
stringResource(Res.string.saved_devices_empty),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = colors.foregroundLight,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopStatusCard() {
|
||||
val colors = LocalVniDropColors.current
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface200),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
PlatformIcon(AppIcon.Check, contentDescription = null, tint = colors.brandLink, modifier = Modifier.size(24.dp))
|
||||
Text(stringResource(Res.string.saved_devices_pending_title), style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(Res.string.saved_devices_no_pending), color = colors.foregroundLight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SavedDevicesLoading(modifier: Modifier = Modifier) {
|
||||
Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
@@ -606,23 +154,6 @@ private fun SavedDevicesLoadFailure(onRetry: () -> Unit, modifier: Modifier = Mo
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InlineLoadFailure(onRetry: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = LocalVniDropColors.current.backgroundSurface200),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(stringResource(Res.string.saved_devices_load_failed), Modifier.weight(1f))
|
||||
TextButton(onClick = onRetry) { Text(stringResource(Res.string.button_retry)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SavedDeviceLabelDialog(
|
||||
visible: Boolean,
|
||||
@@ -649,18 +180,10 @@ private fun SavedDeviceLabelDialog(
|
||||
TextButton(onClick = onSave) { Text(stringResource(Res.string.saved_devices_label_save)) }
|
||||
},
|
||||
dismissButton = {
|
||||
Row {
|
||||
androidx.compose.foundation.layout.Row {
|
||||
TextButton(onClick = onClear) { Text(stringResource(Res.string.saved_devices_label_clear)) }
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(Res.string.button_cancel)) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SavedDeviceModel.displayName(): String = localLabel?.takeIf(String::isNotBlank)
|
||||
?: remoteDisplayName?.takeIf(String::isNotBlank)
|
||||
?: stringResource(Res.string.saved_devices_unnamed)
|
||||
|
||||
private fun shortEndpoint(endpointId: String): String =
|
||||
if (endpointId.length <= 20) endpointId else endpointId.take(16) + "…"
|
||||
|
||||
@@ -6,8 +6,14 @@ import com.vnidrop.app.core.CoreGateway
|
||||
import com.vnidrop.app.core.CoreSignal
|
||||
import com.vnidrop.app.core.DeviceRelationshipModel
|
||||
import com.vnidrop.app.core.DeviceRelationshipStateModel
|
||||
import com.vnidrop.app.core.FileSystemService
|
||||
import com.vnidrop.app.core.PairingEligibilityModel
|
||||
import com.vnidrop.app.core.ReceiveFolder
|
||||
import com.vnidrop.app.core.SavedDeviceModel
|
||||
import com.vnidrop.app.core.TargetedOfferResponseModel
|
||||
import com.vnidrop.app.core.TargetedTransferModel
|
||||
import com.vnidrop.app.core.TargetedTransferStateModel
|
||||
import com.vnidrop.app.preferences.PreferencesRepository
|
||||
import com.vnidrop.app.ui.feedback.UiMessage
|
||||
import com.vnidrop.app.ui.feedback.UiMessageController
|
||||
import com.vnidrop.app.ui.feedback.UiMessageTone
|
||||
@@ -15,11 +21,16 @@ import com.vnidrop.app.ui.feedback.UiText
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.receive_completed
|
||||
import vnidrop.shared.generated.resources.saved_devices_blocked
|
||||
import vnidrop.shared.generated.resources.saved_devices_forgotten
|
||||
import vnidrop.shared.generated.resources.saved_devices_labeled
|
||||
@@ -30,23 +41,41 @@ data class SavedDevicesState(
|
||||
val eligibilities: List<PairingEligibilityModel> = emptyList(),
|
||||
val pendingRelationships: List<DeviceRelationshipModel> = emptyList(),
|
||||
val savedDevices: List<SavedDeviceModel> = emptyList(),
|
||||
val targetedTransfers: List<SavedDeviceTransferItem> = emptyList(),
|
||||
val pairingPrompt: PairingPromptState = PairingPromptState(),
|
||||
val targetedOffers: TargetedOfferState = TargetedOfferState(),
|
||||
val busyPeerIds: Set<String> = emptySet(),
|
||||
val busyTransferIds: Set<String> = emptySet(),
|
||||
val labelingPeerId: String? = null,
|
||||
val labelDraft: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* Product-level Saved-device experience. Callers observe one snapshot and issue
|
||||
* named commands; pairing, direct offers, history, and receive destinations stay internal.
|
||||
*/
|
||||
class SavedDevicesViewModel(
|
||||
private val repository: CoreGateway,
|
||||
private val fileSystemService: FileSystemService,
|
||||
private val preferencesRepository: PreferencesRepository,
|
||||
private val messages: UiMessageController,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(SavedDevicesState())
|
||||
val state: StateFlow<SavedDevicesState> = _state.asStateFlow()
|
||||
|
||||
private val refreshMutex = Mutex()
|
||||
private val dismissedEligibility = mutableSetOf<String>()
|
||||
private var receiveFolder: ReceiveFolder? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
repository.state.map { it.isInitialized }
|
||||
combine(
|
||||
preferencesRepository.preferences,
|
||||
repository.state.map { it.isInitialized },
|
||||
) { preferences, initialized -> preferences.receiveFolder to initialized }
|
||||
.distinctUntilChanged()
|
||||
.collect { initialized ->
|
||||
.collectLatest { (configuredFolder, initialized) ->
|
||||
receiveFolder = fileSystemService.effectiveReceiveFolder(configuredFolder)
|
||||
if (initialized) refresh()
|
||||
}
|
||||
}
|
||||
@@ -58,8 +87,8 @@ class SavedDevicesViewModel(
|
||||
if (repository.state.value.isInitialized) refresh()
|
||||
}
|
||||
is CoreSignal.ApprovalChanged,
|
||||
is CoreSignal.ReceiverHistoryChanged,
|
||||
is CoreSignal.TransfersChanged -> Unit
|
||||
is CoreSignal.ReceiverHistoryChanged,
|
||||
is CoreSignal.TransfersChanged -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,6 +99,44 @@ class SavedDevicesViewModel(
|
||||
viewModelScope.launch { refresh() }
|
||||
}
|
||||
|
||||
fun acceptPairingPrompt() {
|
||||
val prompt = _state.value.pairingPrompt.prompt ?: return
|
||||
if (_state.value.pairingPrompt.busy) return
|
||||
_state.update { it.copy(pairingPrompt = it.pairingPrompt.copy(busy = true)) }
|
||||
viewModelScope.launch {
|
||||
val result = when (prompt) {
|
||||
is PairingPrompt.Eligibility -> repository.requestSavedDevicePairing(prompt.peerEndpointId)
|
||||
is PairingPrompt.IncomingRequest -> repository.respondToDevicePairing(prompt.peerEndpointId, true)
|
||||
}
|
||||
_state.update { it.copy(pairingPrompt = it.pairingPrompt.copy(busy = false)) }
|
||||
result.fold(onSuccess = { refresh() }, onFailure = messages::error)
|
||||
}
|
||||
}
|
||||
|
||||
fun declinePairingPrompt() {
|
||||
val prompt = _state.value.pairingPrompt.prompt ?: return
|
||||
if (_state.value.pairingPrompt.busy) return
|
||||
_state.update { it.copy(pairingPrompt = it.pairingPrompt.copy(busy = true)) }
|
||||
viewModelScope.launch {
|
||||
val result = when (prompt) {
|
||||
is PairingPrompt.Eligibility -> repository.declinePairingEligibility(prompt.peerEndpointId)
|
||||
is PairingPrompt.IncomingRequest -> repository.respondToDevicePairing(prompt.peerEndpointId, false)
|
||||
}
|
||||
_state.update { it.copy(pairingPrompt = it.pairingPrompt.copy(busy = false)) }
|
||||
result.fold(onSuccess = { refresh() }, onFailure = messages::error)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissPairingPrompt() {
|
||||
val prompt = _state.value.pairingPrompt.prompt ?: return
|
||||
if (prompt is PairingPrompt.Eligibility) dismissedEligibility += prompt.peerEndpointId
|
||||
_state.update { it.copy(pairingPrompt = it.pairingPrompt.copy(prompt = null)) }
|
||||
}
|
||||
|
||||
fun acceptTargetedOffer(transferId: String) = respondToTargetedOffer(transferId, accepted = true)
|
||||
|
||||
fun declineTargetedOffer(transferId: String) = respondToTargetedOffer(transferId, accepted = false)
|
||||
|
||||
fun rememberEligible(peerEndpointId: String) = mutatePeer(peerEndpointId) {
|
||||
repository.requestSavedDevicePairing(peerEndpointId).map { }
|
||||
}
|
||||
@@ -86,6 +153,18 @@ class SavedDevicesViewModel(
|
||||
repository.respondToDevicePairing(peerEndpointId, accepted = false).map { }
|
||||
}
|
||||
|
||||
fun receiveTargetedTransfer(transferId: String) = runTransfer(transferId, resume = false)
|
||||
|
||||
fun resumeTargetedTransfer(transferId: String) = runTransfer(transferId, resume = true)
|
||||
|
||||
fun cancelTargetedTransfer(transferId: String) = mutateTransfer(transferId) {
|
||||
repository.cancelTargetedTransfer(transferId)
|
||||
}
|
||||
|
||||
fun deleteTargetedTransfer(transferId: String) = mutateTransfer(transferId) {
|
||||
repository.deleteTargetedTransfer(transferId)
|
||||
}
|
||||
|
||||
fun openLabelEditor(peerEndpointId: String) {
|
||||
val current = _state.value.savedDevices.firstOrNull { it.endpointId == peerEndpointId }
|
||||
_state.update {
|
||||
@@ -134,46 +213,161 @@ class SavedDevicesViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun respondToTargetedOffer(transferId: String, accepted: Boolean) {
|
||||
if (transferId in _state.value.targetedOffers.respondingIds) return
|
||||
_state.update {
|
||||
it.copy(targetedOffers = it.targetedOffers.copy(respondingIds = it.targetedOffers.respondingIds + transferId))
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val response = repository.respondToTargetedOffer(transferId, accepted)
|
||||
response.fold(
|
||||
onSuccess = { result ->
|
||||
if (accepted && result is TargetedOfferResponseModel.Approved) {
|
||||
pullTargetedTransfer(result.transferId, resume = false).onFailure(messages::error)
|
||||
}
|
||||
refresh()
|
||||
},
|
||||
onFailure = messages::error,
|
||||
)
|
||||
_state.update {
|
||||
it.copy(targetedOffers = it.targetedOffers.copy(respondingIds = it.targetedOffers.respondingIds - transferId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun runTransfer(transferId: String, resume: Boolean) = mutateTransfer(transferId) {
|
||||
pullTargetedTransfer(transferId, resume)
|
||||
}
|
||||
|
||||
private suspend fun pullTargetedTransfer(transferId: String, resume: Boolean): Result<Unit> {
|
||||
val folder = receiveFolder ?: return Result.failure(IllegalStateException())
|
||||
val sink = fileSystemService.createReceiveOutputSink(folder)
|
||||
val result = when {
|
||||
resume && sink != null -> repository.resumeTargetedTransferWithOutputSinkV2(transferId, sink)
|
||||
resume -> repository.resumeTargetedTransfer(transferId, folder.value)
|
||||
sink != null -> repository.receiveTargetedTransferWithOutputSinkV2(transferId, sink)
|
||||
else -> repository.receiveTargetedTransfer(transferId, folder.value)
|
||||
}
|
||||
result.onSuccess {
|
||||
messages.tryShow(UiMessage(UiText.Resource(Res.string.receive_completed), UiMessageTone.Success))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun mutatePeer(peerEndpointId: String, block: suspend () -> Result<*>) {
|
||||
if (peerEndpointId in _state.value.busyPeerIds) return
|
||||
_state.update { it.copy(busyPeerIds = it.busyPeerIds + peerEndpointId) }
|
||||
viewModelScope.launch {
|
||||
block().fold(
|
||||
onSuccess = { refresh() },
|
||||
onFailure = messages::error,
|
||||
)
|
||||
block().fold(onSuccess = { refresh() }, onFailure = messages::error)
|
||||
_state.update { it.copy(busyPeerIds = it.busyPeerIds - peerEndpointId) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refresh() {
|
||||
_state.update { it.copy(isLoading = true, loadFailed = false) }
|
||||
val eligibilities = repository.listPairingEligibilities().getOrElse {
|
||||
_state.update { state -> state.copy(isLoading = false, loadFailed = true) }
|
||||
messages.error(it)
|
||||
return
|
||||
}
|
||||
val relationships = repository.listDeviceRelationships().getOrElse {
|
||||
_state.update { state -> state.copy(isLoading = false, loadFailed = true) }
|
||||
messages.error(it)
|
||||
return
|
||||
}
|
||||
val saved = repository.listSavedDevices().getOrElse {
|
||||
_state.update { state -> state.copy(isLoading = false, loadFailed = true) }
|
||||
messages.error(it)
|
||||
return
|
||||
}
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
loadFailed = false,
|
||||
eligibilities = eligibilities.sortedByDescending(PairingEligibilityModel::createdAt),
|
||||
pendingRelationships = relationships.filter {
|
||||
it.state == DeviceRelationshipStateModel.PendingIncoming ||
|
||||
it.state == DeviceRelationshipStateModel.PendingOutgoing
|
||||
}.sortedByDescending(DeviceRelationshipModel::updatedAt),
|
||||
savedDevices = saved.sortedByDescending(SavedDeviceModel::createdAt),
|
||||
)
|
||||
private fun mutateTransfer(transferId: String, block: suspend () -> Result<Unit>) {
|
||||
if (transferId in _state.value.busyTransferIds) return
|
||||
_state.update { it.copy(busyTransferIds = it.busyTransferIds + transferId) }
|
||||
viewModelScope.launch {
|
||||
block().fold(onSuccess = { refresh() }, onFailure = messages::error)
|
||||
_state.update { it.copy(busyTransferIds = it.busyTransferIds - transferId) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refresh() {
|
||||
refreshMutex.withLock {
|
||||
_state.update { it.copy(isLoading = true, loadFailed = false) }
|
||||
val eligibilities = repository.listPairingEligibilities().getOrElse {
|
||||
refreshFailed(it)
|
||||
return@withLock
|
||||
}
|
||||
val relationships = repository.listDeviceRelationships().getOrElse {
|
||||
refreshFailed(it)
|
||||
return@withLock
|
||||
}
|
||||
val savedDevices = repository.listSavedDevices().getOrElse {
|
||||
refreshFailed(it)
|
||||
return@withLock
|
||||
}
|
||||
val pendingOffers = repository.listPendingTargetedOffers().getOrElse {
|
||||
refreshFailed(it)
|
||||
return@withLock
|
||||
}
|
||||
val targetedTransfers = repository.listTargetedTransfers().getOrElse {
|
||||
refreshFailed(it)
|
||||
return@withLock
|
||||
}
|
||||
val savedNames = savedDevices.associate { device -> device.endpointId to device.displayNameOrNull() }
|
||||
.filterValues { it != null }
|
||||
.mapValues { it.value.orEmpty() }
|
||||
val pendingRelationships = relationships.filter {
|
||||
it.state == DeviceRelationshipStateModel.PendingIncoming ||
|
||||
it.state == DeviceRelationshipStateModel.PendingOutgoing
|
||||
}.sortedByDescending(DeviceRelationshipModel::updatedAt)
|
||||
val pairingPrompt = if (_state.value.pairingPrompt.busy) {
|
||||
_state.value.pairingPrompt
|
||||
} else {
|
||||
PairingPromptState(prompt = nextPairingPrompt(pendingRelationships, eligibilities, savedNames))
|
||||
}
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
loadFailed = false,
|
||||
eligibilities = eligibilities.sortedByDescending(PairingEligibilityModel::createdAt),
|
||||
pendingRelationships = pendingRelationships,
|
||||
savedDevices = savedDevices.sortedByDescending(SavedDeviceModel::createdAt),
|
||||
targetedTransfers = targetedTransfers
|
||||
.filterNot { transfer -> transfer.state == TargetedTransferStateModel.Deleted }
|
||||
.sortedByDescending(TargetedTransferModel::updatedAt)
|
||||
.map { transfer -> transfer.toExperienceItem(savedNames) },
|
||||
pairingPrompt = pairingPrompt,
|
||||
targetedOffers = it.targetedOffers.copy(
|
||||
pending = pendingOffers.sortedBy { offer -> offer.receivedAt },
|
||||
senderDisplayNames = savedNames,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshFailed(error: Throwable) {
|
||||
_state.update { it.copy(isLoading = false, loadFailed = true) }
|
||||
messages.error(error)
|
||||
}
|
||||
|
||||
private fun nextPairingPrompt(
|
||||
relationships: List<DeviceRelationshipModel>,
|
||||
eligibilities: List<PairingEligibilityModel>,
|
||||
savedNames: Map<String, String>,
|
||||
): PairingPrompt? {
|
||||
val incoming = relationships.firstOrNull { it.state == DeviceRelationshipStateModel.PendingIncoming }
|
||||
if (incoming != null) {
|
||||
val name = eligibilities.firstOrNull { it.peerEndpointId == incoming.remoteEndpointId }?.remoteDisplayName
|
||||
?: savedNames[incoming.remoteEndpointId]
|
||||
return PairingPrompt.IncomingRequest(incoming.remoteEndpointId, name)
|
||||
}
|
||||
return eligibilities.firstOrNull { it.peerEndpointId !in dismissedEligibility }?.let {
|
||||
PairingPrompt.Eligibility(it.peerEndpointId, it.remoteDisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun TargetedTransferModel.toExperienceItem(savedNames: Map<String, String>): SavedDeviceTransferItem {
|
||||
val localEndpointId = repository.state.value.status?.endpointId
|
||||
val outgoing = senderEndpointId == localEndpointId
|
||||
val peerEndpointId = if (outgoing) receiverEndpointId else senderEndpointId
|
||||
return SavedDeviceTransferItem(
|
||||
id = id,
|
||||
peerEndpointId = peerEndpointId,
|
||||
peerDisplayName = savedNames[peerEndpointId],
|
||||
direction = if (outgoing) SavedDeviceTransferDirection.Outgoing else SavedDeviceTransferDirection.Incoming,
|
||||
transferName = transferName,
|
||||
fileCount = fileCount,
|
||||
totalSize = totalSize,
|
||||
verifiedBytes = verifiedBytes,
|
||||
state = state,
|
||||
createdAt = createdAt,
|
||||
updatedAt = updatedAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun SavedDeviceModel.displayNameOrNull(): String? =
|
||||
localLabel?.takeIf(String::isNotBlank) ?: remoteDisplayName?.takeIf(String::isNotBlank)
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
package com.vnidrop.app.feature.saveddevices
|
||||
|
||||
import com.vnidrop.app.core.CoreGateway
|
||||
import com.vnidrop.app.core.CoreSignal
|
||||
import com.vnidrop.app.core.FileSystemService
|
||||
import com.vnidrop.app.core.PendingTargetedOfferModel
|
||||
import com.vnidrop.app.core.ReceiveFolder
|
||||
import com.vnidrop.app.core.TargetedOfferResponseModel
|
||||
import com.vnidrop.app.preferences.PreferencesRepository
|
||||
import com.vnidrop.app.ui.feedback.UiMessage
|
||||
import com.vnidrop.app.ui.feedback.UiMessageController
|
||||
import com.vnidrop.app.ui.feedback.UiMessageTone
|
||||
import com.vnidrop.app.ui.feedback.UiText
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.receive_completed
|
||||
|
||||
data class TargetedOfferState(
|
||||
val pending: List<PendingTargetedOfferModel> = emptyList(),
|
||||
val senderDisplayNames: Map<String, String> = emptyMap(),
|
||||
val respondingIds: Set<String> = emptySet(),
|
||||
) {
|
||||
val current: PendingTargetedOfferModel?
|
||||
get() = pending.firstOrNull()
|
||||
|
||||
val currentSenderDisplayName: String?
|
||||
get() = current?.senderEndpointId?.let(senderDisplayNames::get)
|
||||
}
|
||||
|
||||
/**
|
||||
* Foreground interrupt for pending targeted offers. Approve pulls by transfer id
|
||||
* through the platform receive destination (MediaStore Downloads sink on Android,
|
||||
* filesystem path on desktop when no sink is available).
|
||||
*/
|
||||
class TargetedOfferCoordinator(
|
||||
private val repository: CoreGateway,
|
||||
private val fileSystemService: FileSystemService,
|
||||
private val preferencesRepository: PreferencesRepository,
|
||||
private val messages: UiMessageController,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val _state = MutableStateFlow(TargetedOfferState())
|
||||
val state: StateFlow<TargetedOfferState> = _state.asStateFlow()
|
||||
|
||||
private var receiveFolder: ReceiveFolder? = null
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
// Same startup race as PairingPromptCoordinator: prefs can load while
|
||||
// core initialize still holds the lifecycle gate / core is null.
|
||||
combine(
|
||||
preferencesRepository.preferences,
|
||||
repository.state.map { it.isInitialized },
|
||||
) { preferences, initialized -> preferences to initialized }
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { (preferences, initialized) ->
|
||||
receiveFolder = fileSystemService.effectiveReceiveFolder(preferences.receiveFolder)
|
||||
if (initialized) refresh()
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
repository.signals.collect { signal ->
|
||||
when (signal) {
|
||||
CoreSignal.TargetedTransferChanged -> {
|
||||
if (repository.state.value.isInitialized) refresh()
|
||||
}
|
||||
CoreSignal.PairingChanged,
|
||||
is CoreSignal.ApprovalChanged,
|
||||
is CoreSignal.ReceiverHistoryChanged,
|
||||
is CoreSignal.TransfersChanged -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun accept(transferId: String) = respond(transferId, accepted = true)
|
||||
|
||||
fun decline(transferId: String) = respond(transferId, accepted = false)
|
||||
|
||||
private fun respond(transferId: String, accepted: Boolean) {
|
||||
if (transferId in _state.value.respondingIds) return
|
||||
_state.update { it.copy(respondingIds = it.respondingIds + transferId) }
|
||||
scope.launch {
|
||||
val result = repository.respondToTargetedOffer(transferId, accepted)
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
if (accepted && response is TargetedOfferResponseModel.Approved) {
|
||||
pull(response.transferId)
|
||||
}
|
||||
refresh()
|
||||
},
|
||||
onFailure = messages::error,
|
||||
)
|
||||
_state.update { it.copy(respondingIds = it.respondingIds - transferId) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pull(transferId: String) {
|
||||
val folder = receiveFolder ?: return
|
||||
val sink = fileSystemService.createReceiveOutputSink(folder)
|
||||
val result = if (sink != null) {
|
||||
repository.receiveTargetedTransferWithOutputSinkV2(transferId, sink)
|
||||
} else {
|
||||
repository.receiveTargetedTransfer(transferId, folder.value)
|
||||
}
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
messages.tryShow(
|
||||
UiMessage(UiText.Resource(Res.string.receive_completed), UiMessageTone.Success),
|
||||
)
|
||||
},
|
||||
onFailure = messages::error,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun refresh() {
|
||||
val offers = repository.listPendingTargetedOffers().getOrElse {
|
||||
messages.error(it)
|
||||
return
|
||||
}
|
||||
val savedDevices = repository.listSavedDevices().getOrElse {
|
||||
messages.error(it)
|
||||
return
|
||||
}
|
||||
_state.update {
|
||||
it.copy(
|
||||
pending = offers.sortedBy(PendingTargetedOfferModel::receivedAt),
|
||||
senderDisplayNames = savedDevices.associate { device ->
|
||||
device.endpointId to (
|
||||
device.localLabel?.takeIf(String::isNotBlank)
|
||||
?: device.remoteDisplayName?.takeIf(String::isNotBlank)
|
||||
).orEmpty()
|
||||
}.filterValues(String::isNotBlank),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
package com.vnidrop.app.feature.saveddevices
|
||||
|
||||
import com.vnidrop.app.core.DeviceRelationshipModel
|
||||
import com.vnidrop.app.core.DeviceRelationshipStateModel
|
||||
import com.vnidrop.app.core.PairingEligibilityModel
|
||||
import com.vnidrop.app.support.FakeCoreGateway
|
||||
import com.vnidrop.app.ui.feedback.UiMessageController
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class PairingPromptCoordinatorTest {
|
||||
@Test
|
||||
fun waitsForCoreInitializeBeforeRefreshing() = runTest {
|
||||
// The coordinator must not query domain state before AppViewModel initializes the core.
|
||||
val core = FakeCoreGateway().apply {
|
||||
pairingEligibilities = listOf(eligibility("peer-a"))
|
||||
}
|
||||
val messages = UiMessageController()
|
||||
val seen = mutableListOf<String>()
|
||||
backgroundScope.launch {
|
||||
messages.messages.collect { seen += it.text.toString() }
|
||||
}
|
||||
val coordinator = PairingPromptCoordinator(
|
||||
core,
|
||||
messages,
|
||||
backgroundScope,
|
||||
)
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
assertEquals(0, core.listDeviceRelationshipsCount)
|
||||
assertNull(coordinator.state.value.prompt)
|
||||
assertTrue(seen.isEmpty())
|
||||
|
||||
core.mutableState.value = core.mutableState.value.copy(isInitialized = true)
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
assertEquals(1, core.listDeviceRelationshipsCount)
|
||||
assertEquals(PairingPrompt.Eligibility("peer-a", "Remote device"), coordinator.state.value.prompt)
|
||||
assertTrue(seen.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptEligibilityRequestsPairing() = runTest {
|
||||
val core = initializedCore().apply {
|
||||
pairingEligibilities = listOf(eligibility("peer-a"))
|
||||
}
|
||||
val coordinator = PairingPromptCoordinator(
|
||||
core,
|
||||
UiMessageController(),
|
||||
backgroundScope,
|
||||
)
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
assertEquals(PairingPrompt.Eligibility("peer-a", "Remote device"), coordinator.state.value.prompt)
|
||||
|
||||
coordinator.accept()
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
assertEquals(listOf("peer-a"), core.requestedPairings)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun declineEligibilityConsumesWithoutRequest() = runTest {
|
||||
val core = initializedCore().apply {
|
||||
pairingEligibilities = listOf(eligibility("peer-a"))
|
||||
}
|
||||
val coordinator = PairingPromptCoordinator(
|
||||
core,
|
||||
UiMessageController(),
|
||||
backgroundScope,
|
||||
)
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
coordinator.decline()
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
assertTrue(core.requestedPairings.isEmpty())
|
||||
assertTrue(core.pairingEligibilities.none { it.peerEndpointId == "peer-a" })
|
||||
assertNull(coordinator.state.value.prompt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dismissKeepsEligibilityForSavedDevicesArea() = runTest {
|
||||
val core = initializedCore().apply {
|
||||
pairingEligibilities = listOf(eligibility("peer-a"))
|
||||
}
|
||||
val coordinator = PairingPromptCoordinator(
|
||||
core,
|
||||
UiMessageController(),
|
||||
backgroundScope,
|
||||
)
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
coordinator.dismiss()
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
assertNull(coordinator.state.value.prompt)
|
||||
assertEquals(1, core.pairingEligibilities.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun incomingPairingRequestAcceptsViaRespond() = runTest {
|
||||
val core = initializedCore().apply {
|
||||
deviceRelationships = listOf(incoming("peer-b"))
|
||||
}
|
||||
val coordinator = PairingPromptCoordinator(
|
||||
core,
|
||||
UiMessageController(),
|
||||
backgroundScope,
|
||||
)
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
assertIs<PairingPrompt.IncomingRequest>(coordinator.state.value.prompt)
|
||||
|
||||
coordinator.accept()
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
assertEquals(listOf("peer-b" to true), core.pairingResponses)
|
||||
}
|
||||
|
||||
private fun initializedCore() = FakeCoreGateway().apply {
|
||||
mutableState.value = mutableState.value.copy(isInitialized = true)
|
||||
}
|
||||
|
||||
private fun eligibility(peer: String) = PairingEligibilityModel(
|
||||
peerEndpointId = peer,
|
||||
remoteDisplayName = "Remote device",
|
||||
sessionId = "session",
|
||||
protocolVersion = 1u,
|
||||
createdAt = 1L,
|
||||
expiresAt = 2L,
|
||||
)
|
||||
|
||||
private fun incoming(peer: String) = DeviceRelationshipModel(
|
||||
remoteEndpointId = peer,
|
||||
state = DeviceRelationshipStateModel.PendingIncoming,
|
||||
generation = 1u,
|
||||
minimumProtocolVersion = 1u,
|
||||
createdAt = 1L,
|
||||
updatedAt = 1L,
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,22 @@
|
||||
package com.vnidrop.app.feature.saveddevices
|
||||
|
||||
import com.vnidrop.app.core.CoreStatus
|
||||
import com.vnidrop.app.core.DeviceRelationshipModel
|
||||
import com.vnidrop.app.core.DeviceRelationshipStateModel
|
||||
import com.vnidrop.app.core.PairingEligibilityModel
|
||||
import com.vnidrop.app.core.PendingTargetedOfferModel
|
||||
import com.vnidrop.app.core.ReceiveFolder
|
||||
import com.vnidrop.app.core.ReceiveFolderKind
|
||||
import com.vnidrop.app.core.SavedDeviceModel
|
||||
import com.vnidrop.app.core.TargetedOfferResponseModel
|
||||
import com.vnidrop.app.core.TargetedTransferModel
|
||||
import com.vnidrop.app.core.TargetedTransferStateModel
|
||||
import com.vnidrop.app.preferences.AppPreferences
|
||||
import com.vnidrop.app.support.FakeCoreGateway
|
||||
import com.vnidrop.app.support.FakeFileSystemService
|
||||
import com.vnidrop.app.support.FakePreferencesRepository
|
||||
import com.vnidrop.app.ui.feedback.UiMessageController
|
||||
import com.vnidrop.app.ui.theme.ThemeMode
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
@@ -11,9 +25,11 @@ import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import uniffi.vnidrop.ReceiveOutputSinkV2
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@@ -25,70 +41,281 @@ class SavedDevicesViewModelTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadsSavedDevicesWithoutAnExperimentalPreferenceGate() = runTest {
|
||||
fun initializationLoadsOneSavedDeviceExperienceSnapshot() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val core = FakeCoreGateway().apply {
|
||||
mutableState.value = mutableState.value.copy(isInitialized = true)
|
||||
savedDevices = listOf(device("peer-always-visible", label = null))
|
||||
pairingEligibilities = listOf(eligibility("eligible", "Phone"))
|
||||
deviceRelationships = listOf(incoming("incoming"))
|
||||
savedDevices = listOf(device("peer", "Office PC", "Authenticated PC"))
|
||||
pendingTargetedOffers = listOf(offer("offer", "peer"))
|
||||
targetedTransfers = listOf(transfer("history", "local", "peer", TargetedTransferStateModel.AwaitingApproval))
|
||||
}
|
||||
val viewModel = SavedDevicesViewModel(core, UiMessageController())
|
||||
val viewModel = createViewModel(core)
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
assertEquals(0, core.listSavedDevicesCount)
|
||||
|
||||
core.mutableState.value = core.mutableState.value.copy(
|
||||
isInitialized = true,
|
||||
status = CoreStatus("local", 0u, 0u),
|
||||
)
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("peer-always-visible", viewModel.state.value.savedDevices.single().endpointId)
|
||||
assertEquals(false, viewModel.state.value.isLoading)
|
||||
assertEquals(false, viewModel.state.value.loadFailed)
|
||||
val state = viewModel.state.value
|
||||
assertEquals("peer", state.savedDevices.single().endpointId)
|
||||
assertEquals("offer", state.targetedOffers.current?.transferId)
|
||||
assertEquals("Office PC", state.targetedOffers.currentSenderDisplayName)
|
||||
assertEquals(SavedDeviceTransferDirection.Outgoing, state.targetedTransfers.single().direction)
|
||||
assertEquals("Office PC", state.targetedTransfers.single().peerDisplayName)
|
||||
assertIs<PairingPrompt.IncomingRequest>(state.pairingPrompt.prompt)
|
||||
assertEquals(false, state.isLoading)
|
||||
assertEquals(false, state.loadFailed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pairingPromptCommandsAndDismissalUseTheSameDurableLists() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val core = initializedCore().apply {
|
||||
pairingEligibilities = listOf(eligibility("peer-a", "Remote device"))
|
||||
}
|
||||
val viewModel = createViewModel(core)
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
assertEquals(PairingPrompt.Eligibility("peer-a", "Remote device"), viewModel.state.value.pairingPrompt.prompt)
|
||||
|
||||
viewModel.dismissPairingPrompt()
|
||||
runCurrent()
|
||||
assertNull(viewModel.state.value.pairingPrompt.prompt)
|
||||
assertEquals("peer-a", viewModel.state.value.eligibilities.single().peerEndpointId)
|
||||
|
||||
viewModel.rememberEligible("peer-a")
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
assertEquals(listOf("peer-a"), core.requestedPairings)
|
||||
|
||||
core.deviceRelationships = listOf(incoming("peer-b"))
|
||||
core.pairingEligibilities = emptyList()
|
||||
core.mutableSignals.emit(com.vnidrop.app.core.CoreSignal.PairingChanged)
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
viewModel.acceptPairingPrompt()
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
assertEquals(listOf("peer-b" to true), core.pairingResponses)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptingOfferPullsByIdThroughThePlatformSink() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val sink = unusedSink()
|
||||
val core = initializedCore().apply {
|
||||
pendingTargetedOffers = listOf(offer("transfer-sink", "sender"))
|
||||
respondTargetedResult = Result.success(TargetedOfferResponseModel.Approved("transfer-sink"))
|
||||
}
|
||||
val viewModel = createViewModel(
|
||||
core,
|
||||
FakeFileSystemService(receiveFolder(), receiveOutputSink = sink),
|
||||
)
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.acceptTargetedOffer("transfer-sink")
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf("transfer-sink" to true), core.respondedTargetedOffers)
|
||||
assertEquals(listOf("transfer-sink"), core.receivedTargetedViaSinkIds)
|
||||
assertTrue(core.receivedTargetedPathDirs.isEmpty())
|
||||
assertNull(viewModel.state.value.targetedOffers.current)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decliningEligibilityAndOfferConsumeOnlyTheirOwnPendingItems() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val core = initializedCore().apply {
|
||||
pairingEligibilities = listOf(eligibility("pairing", "Phone"))
|
||||
pendingTargetedOffers = listOf(offer("offer", "sender"))
|
||||
respondTargetedResult = Result.success(TargetedOfferResponseModel.Declined)
|
||||
}
|
||||
val viewModel = createViewModel(core)
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.declinePairingPrompt()
|
||||
viewModel.declineTargetedOffer("offer")
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(core.pairingEligibilities.isEmpty())
|
||||
assertEquals(listOf("offer" to false), core.respondedTargetedOffers)
|
||||
assertTrue(core.receivedTargetedTransferIds.isEmpty())
|
||||
assertNull(viewModel.state.value.targetedOffers.current)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interruptedTransferResumeUsesThePlatformSinkWhenAvailable() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val core = initializedCore().apply {
|
||||
targetedTransfers = listOf(transfer("resume-sink", "peer", "local", TargetedTransferStateModel.Interrupted))
|
||||
}
|
||||
val viewModel = createViewModel(
|
||||
core,
|
||||
FakeFileSystemService(receiveFolder(), receiveOutputSink = unusedSink()),
|
||||
)
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.resumeTargetedTransfer("resume-sink")
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf("resume-sink"), core.resumedTargetedViaSinkIds)
|
||||
assertTrue(core.resumedTargetedPathDirs.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transferHistoryResumeCancelAndDeleteStayTransferIdScoped() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val core = initializedCore().apply {
|
||||
targetedTransfers = listOf(
|
||||
transfer("resume", "peer", "local", TargetedTransferStateModel.Interrupted),
|
||||
transfer("cancel", "local", "peer", TargetedTransferStateModel.AwaitingApproval),
|
||||
transfer("delete", "peer", "local", TargetedTransferStateModel.Completed),
|
||||
)
|
||||
}
|
||||
val viewModel = createViewModel(core)
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.resumeTargetedTransfer("resume")
|
||||
viewModel.cancelTargetedTransfer("cancel")
|
||||
viewModel.deleteTargetedTransfer("delete")
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf("resume"), core.resumedTargetedTransferIds)
|
||||
assertEquals(listOf("resume" to "/tmp"), core.resumedTargetedPathDirs)
|
||||
assertEquals(listOf("cancel"), core.cancelledTargetedTransferIds)
|
||||
assertEquals(listOf("delete"), core.deletedTargetedTransferIds)
|
||||
assertTrue(viewModel.state.value.targetedTransfers.none { it.id == "delete" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun labelForgetAndBlockUpdateGateway() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val core = FakeCoreGateway().apply {
|
||||
mutableState.value = mutableState.value.copy(isInitialized = true)
|
||||
savedDevices = listOf(device("peer-1", label = null))
|
||||
}
|
||||
val viewModel = SavedDevicesViewModel(
|
||||
core,
|
||||
UiMessageController(),
|
||||
)
|
||||
val core = initializedCore().apply { savedDevices = listOf(device("peer-1", null, null)) }
|
||||
val viewModel = createViewModel(core)
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
assertEquals(1, viewModel.state.value.savedDevices.size)
|
||||
|
||||
viewModel.openLabelEditor("peer-1")
|
||||
viewModel.setLabelDraft("Kitchen")
|
||||
viewModel.saveLabel()
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
assertEquals(listOf<Pair<String, String?>>("peer-1" to "Kitchen"), core.labeledDevices.toList())
|
||||
assertEquals("Kitchen", viewModel.state.value.savedDevices.single().localLabel)
|
||||
|
||||
viewModel.openLabelEditor("peer-1")
|
||||
viewModel.clearLabel()
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
assertEquals(listOf<Pair<String, String?>>("peer-1" to "Kitchen", "peer-1" to null), core.labeledDevices.toList())
|
||||
assertNull(viewModel.state.value.savedDevices.single().localLabel)
|
||||
assertEquals(listOf<Pair<String, String?>>("peer-1" to "Kitchen"), core.labeledDevices)
|
||||
|
||||
viewModel.forget("peer-1")
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
assertEquals(listOf("peer-1"), core.forgottenDevices.toList())
|
||||
assertTrue(viewModel.state.value.savedDevices.isEmpty())
|
||||
assertEquals(listOf("peer-1"), core.forgottenDevices)
|
||||
|
||||
core.savedDevices = listOf(device("peer-2", label = "Desk"))
|
||||
core.savedDevices = listOf(device("peer-2", "Desk", null))
|
||||
viewModel.block("peer-2")
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
assertEquals(listOf("peer-2"), core.blockedPeers.toList())
|
||||
assertEquals(listOf("peer-2"), core.blockedPeers)
|
||||
}
|
||||
|
||||
private fun device(id: String, label: String?) = SavedDeviceModel(
|
||||
private fun createViewModel(
|
||||
core: FakeCoreGateway,
|
||||
fileSystemService: FakeFileSystemService = FakeFileSystemService(receiveFolder()),
|
||||
) = SavedDevicesViewModel(
|
||||
repository = core,
|
||||
fileSystemService = fileSystemService,
|
||||
preferencesRepository = FakePreferencesRepository(preferences()),
|
||||
messages = UiMessageController(),
|
||||
)
|
||||
|
||||
private fun initializedCore() = FakeCoreGateway().apply {
|
||||
mutableState.value = mutableState.value.copy(
|
||||
isInitialized = true,
|
||||
status = CoreStatus("local", 0u, 0u),
|
||||
)
|
||||
}
|
||||
|
||||
private fun eligibility(peer: String, name: String) = PairingEligibilityModel(
|
||||
peerEndpointId = peer,
|
||||
remoteDisplayName = name,
|
||||
sessionId = "session",
|
||||
protocolVersion = 1u,
|
||||
createdAt = 1L,
|
||||
expiresAt = 2L,
|
||||
)
|
||||
|
||||
private fun incoming(peer: String) = DeviceRelationshipModel(
|
||||
remoteEndpointId = peer,
|
||||
state = DeviceRelationshipStateModel.PendingIncoming,
|
||||
generation = 1u,
|
||||
minimumProtocolVersion = 1u,
|
||||
createdAt = 1L,
|
||||
updatedAt = 2L,
|
||||
)
|
||||
|
||||
private fun device(id: String, label: String?, remoteName: String?) = SavedDeviceModel(
|
||||
endpointId = id,
|
||||
localLabel = label,
|
||||
remoteDisplayName = null,
|
||||
remoteDisplayName = remoteName,
|
||||
createdAt = 1L,
|
||||
lastAuthenticatedAt = null,
|
||||
lastAuthenticatedAt = 2L,
|
||||
)
|
||||
|
||||
private fun offer(id: String, sender: String) = PendingTargetedOfferModel(
|
||||
transferId = id,
|
||||
senderEndpointId = sender,
|
||||
receiverEndpointId = "local",
|
||||
manifestId = "manifest",
|
||||
contentHash = "hash",
|
||||
transferName = "Photos",
|
||||
fileCount = 1u,
|
||||
totalSize = 10u,
|
||||
protocolVersion = 1u,
|
||||
receivedAt = 1L,
|
||||
)
|
||||
|
||||
private fun transfer(
|
||||
id: String,
|
||||
sender: String,
|
||||
receiver: String,
|
||||
state: TargetedTransferStateModel,
|
||||
) = TargetedTransferModel(
|
||||
id = id,
|
||||
senderEndpointId = sender,
|
||||
receiverEndpointId = receiver,
|
||||
manifestId = "manifest-$id",
|
||||
transferName = "Transfer $id",
|
||||
fileCount = 2u,
|
||||
totalSize = 100u,
|
||||
verifiedBytes = if (state == TargetedTransferStateModel.Completed) 100u else 40u,
|
||||
state = state,
|
||||
createdAt = 1L,
|
||||
updatedAt = 2L,
|
||||
)
|
||||
|
||||
private fun receiveFolder() = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp")
|
||||
|
||||
private fun preferences() = AppPreferences(
|
||||
username = "User",
|
||||
receiveFolder = receiveFolder(),
|
||||
themeMode = ThemeMode.System,
|
||||
notificationsEnabled = false,
|
||||
)
|
||||
|
||||
private fun unusedSink() = object : ReceiveOutputSinkV2 {
|
||||
override fun startFile(relativePath: String) = error("unused")
|
||||
override fun writeChunk(relativePath: String, bytes: ByteArray) = error("unused")
|
||||
override fun finishFile(relativePath: String) = error("unused")
|
||||
override fun abortFile(relativePath: String, reason: String) = error("unused")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
package com.vnidrop.app.feature.saveddevices
|
||||
|
||||
import com.vnidrop.app.core.PendingTargetedOfferModel
|
||||
import com.vnidrop.app.core.ReceiveFolder
|
||||
import com.vnidrop.app.core.ReceiveFolderKind
|
||||
import com.vnidrop.app.core.SavedDeviceModel
|
||||
import com.vnidrop.app.core.TargetedOfferResponseModel
|
||||
import com.vnidrop.app.preferences.AppPreferences
|
||||
import com.vnidrop.app.support.FakeCoreGateway
|
||||
import com.vnidrop.app.support.FakeFileSystemService
|
||||
import com.vnidrop.app.support.FakePreferencesRepository
|
||||
import com.vnidrop.app.ui.feedback.UiMessageController
|
||||
import com.vnidrop.app.ui.theme.ThemeMode
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import uniffi.vnidrop.ReceiveOutputSinkV2
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class TargetedOfferCoordinatorTest {
|
||||
@Test
|
||||
fun pendingOfferUsesTheSavedDevicesDisplayNamePolicy() = runTest {
|
||||
val core = initializedCore().apply {
|
||||
pendingTargetedOffers = listOf(offer("named-transfer"))
|
||||
savedDevices = listOf(
|
||||
SavedDeviceModel(
|
||||
endpointId = "sender",
|
||||
localLabel = "Office PC",
|
||||
remoteDisplayName = "Amira's laptop",
|
||||
createdAt = 1,
|
||||
lastAuthenticatedAt = 2,
|
||||
),
|
||||
)
|
||||
}
|
||||
val coordinator = TargetedOfferCoordinator(
|
||||
core,
|
||||
FakeFileSystemService(ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp")),
|
||||
preferences(),
|
||||
UiMessageController(),
|
||||
backgroundScope,
|
||||
)
|
||||
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("Office PC", coordinator.state.value.currentSenderDisplayName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptApprovesAndPullsByTransferId() = runTest {
|
||||
val core = initializedCore().apply {
|
||||
pendingTargetedOffers = listOf(offer("transfer-1"))
|
||||
respondTargetedResult = Result.success(TargetedOfferResponseModel.Approved("transfer-1"))
|
||||
receiveResult = Result.success(Unit)
|
||||
}
|
||||
val coordinator = TargetedOfferCoordinator(
|
||||
core,
|
||||
FakeFileSystemService(ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp")),
|
||||
preferences(),
|
||||
UiMessageController(),
|
||||
backgroundScope,
|
||||
)
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
assertEquals("transfer-1", coordinator.state.value.current?.transferId)
|
||||
|
||||
coordinator.accept("transfer-1")
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
assertEquals(listOf("transfer-1" to true), core.respondedTargetedOffers)
|
||||
assertEquals(listOf("transfer-1"), core.receivedTargetedTransferIds)
|
||||
assertEquals(listOf("transfer-1" to "/tmp"), core.receivedTargetedPathDirs)
|
||||
assertTrue(core.receivedTargetedViaSinkIds.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptUsesOutputSinkWhenPlatformProvidesOne() = runTest {
|
||||
val sink = object : ReceiveOutputSinkV2 {
|
||||
override fun startFile(relativePath: String) = error("unused")
|
||||
override fun writeChunk(relativePath: String, bytes: ByteArray) = error("unused")
|
||||
override fun finishFile(relativePath: String) = error("unused")
|
||||
override fun abortFile(relativePath: String, reason: String) = error("unused")
|
||||
}
|
||||
val core = initializedCore().apply {
|
||||
pendingTargetedOffers = listOf(offer("transfer-sink"))
|
||||
respondTargetedResult = Result.success(TargetedOfferResponseModel.Approved("transfer-sink"))
|
||||
receiveResult = Result.success(Unit)
|
||||
}
|
||||
val coordinator = TargetedOfferCoordinator(
|
||||
core,
|
||||
FakeFileSystemService(
|
||||
ReceiveFolder(ReceiveFolderKind.AndroidPublicDownloads, "downloads", "Downloads"),
|
||||
receiveOutputSink = sink,
|
||||
),
|
||||
preferences(),
|
||||
UiMessageController(),
|
||||
backgroundScope,
|
||||
)
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
coordinator.accept("transfer-sink")
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
assertEquals(listOf("transfer-sink"), core.receivedTargetedViaSinkIds)
|
||||
assertTrue(core.receivedTargetedPathDirs.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun declineDoesNotReceive() = runTest {
|
||||
val core = initializedCore().apply {
|
||||
pendingTargetedOffers = listOf(offer("transfer-2"))
|
||||
respondTargetedResult = Result.success(TargetedOfferResponseModel.Declined)
|
||||
}
|
||||
val coordinator = TargetedOfferCoordinator(
|
||||
core,
|
||||
FakeFileSystemService(ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp")),
|
||||
preferences(),
|
||||
UiMessageController(),
|
||||
backgroundScope,
|
||||
)
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
coordinator.decline("transfer-2")
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
assertEquals(listOf("transfer-2" to false), core.respondedTargetedOffers)
|
||||
assertTrue(core.receivedTargetedTransferIds.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun waitsForCoreInitializeBeforeListingOffers() = runTest {
|
||||
val core = FakeCoreGateway().apply {
|
||||
pendingTargetedOffers = listOf(offer("transfer-late"))
|
||||
}
|
||||
val coordinator = TargetedOfferCoordinator(
|
||||
core,
|
||||
FakeFileSystemService(ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp")),
|
||||
preferences(),
|
||||
UiMessageController(),
|
||||
backgroundScope,
|
||||
)
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
assertEquals(0, core.listPendingTargetedOffersCount)
|
||||
assertTrue(coordinator.state.value.pending.isEmpty())
|
||||
|
||||
core.mutableState.value = core.mutableState.value.copy(isInitialized = true)
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
assertEquals(1, core.listPendingTargetedOffersCount)
|
||||
assertEquals("transfer-late", coordinator.state.value.current?.transferId)
|
||||
}
|
||||
|
||||
private fun initializedCore() = FakeCoreGateway().apply {
|
||||
mutableState.value = mutableState.value.copy(isInitialized = true)
|
||||
}
|
||||
|
||||
private fun offer(id: String) = PendingTargetedOfferModel(
|
||||
transferId = id,
|
||||
senderEndpointId = "sender",
|
||||
receiverEndpointId = "receiver",
|
||||
manifestId = "manifest",
|
||||
contentHash = "hash",
|
||||
transferName = "Photos",
|
||||
fileCount = 1u,
|
||||
totalSize = 10u,
|
||||
protocolVersion = 1u,
|
||||
receivedAt = 1L,
|
||||
)
|
||||
|
||||
private fun preferences() = FakePreferencesRepository(
|
||||
AppPreferences(
|
||||
username = "User",
|
||||
receiveFolder = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp"),
|
||||
themeMode = ThemeMode.System,
|
||||
notificationsEnabled = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -238,6 +238,11 @@ class FakeCoreGateway : CoreGateway {
|
||||
val receivedTargetedTransferIds = mutableListOf<String>()
|
||||
val receivedTargetedPathDirs = mutableListOf<Pair<String, String>>()
|
||||
val receivedTargetedViaSinkIds = mutableListOf<String>()
|
||||
val resumedTargetedTransferIds = mutableListOf<String>()
|
||||
val resumedTargetedPathDirs = mutableListOf<Pair<String, String>>()
|
||||
val resumedTargetedViaSinkIds = mutableListOf<String>()
|
||||
val cancelledTargetedTransferIds = mutableListOf<String>()
|
||||
val deletedTargetedTransferIds = mutableListOf<String>()
|
||||
val respondedTargetedOffers = mutableListOf<Pair<String, Boolean>>()
|
||||
|
||||
override suspend fun requestSavedDevicePairing(peerEndpointId: String): Result<Boolean> {
|
||||
@@ -284,7 +289,9 @@ class FakeCoreGateway : CoreGateway {
|
||||
}
|
||||
override suspend fun respondToTargetedOffer(transferId: String, accepted: Boolean): Result<TargetedOfferResponseModel> {
|
||||
respondedTargetedOffers += transferId to accepted
|
||||
return respondTargetedResult
|
||||
return respondTargetedResult.onSuccess {
|
||||
pendingTargetedOffers = pendingTargetedOffers.filterNot { offer -> offer.transferId == transferId }
|
||||
}
|
||||
}
|
||||
override suspend fun createTargetedTransfer(
|
||||
receiverEndpointId: String,
|
||||
@@ -318,9 +325,31 @@ class FakeCoreGateway : CoreGateway {
|
||||
receivedTargetedViaSinkIds += transferId
|
||||
return receiveResult
|
||||
}
|
||||
override suspend fun resumeTargetedTransfer(id: String, outputDir: String) = receiveResult
|
||||
override suspend fun cancelTargetedTransfer(id: String) = Result.success(Unit)
|
||||
override suspend fun deleteTargetedTransfer(id: String) = Result.success(Unit)
|
||||
override suspend fun resumeTargetedTransfer(id: String, outputDir: String): Result<Unit> {
|
||||
resumedTargetedTransferIds += id
|
||||
resumedTargetedPathDirs += id to outputDir
|
||||
return receiveResult
|
||||
}
|
||||
override suspend fun resumeTargetedTransferWithOutputSinkV2(
|
||||
id: String,
|
||||
outputSink: ReceiveOutputSinkV2,
|
||||
): Result<Unit> {
|
||||
resumedTargetedTransferIds += id
|
||||
resumedTargetedViaSinkIds += id
|
||||
return receiveResult
|
||||
}
|
||||
override suspend fun cancelTargetedTransfer(id: String): Result<Unit> {
|
||||
cancelledTargetedTransferIds += id
|
||||
targetedTransfers = targetedTransfers.map { transfer ->
|
||||
if (transfer.id == id) transfer.copy(state = com.vnidrop.app.core.TargetedTransferStateModel.Cancelled) else transfer
|
||||
}
|
||||
return Result.success(Unit)
|
||||
}
|
||||
override suspend fun deleteTargetedTransfer(id: String): Result<Unit> {
|
||||
deletedTargetedTransferIds += id
|
||||
targetedTransfers = targetedTransfers.filterNot { it.id == id }
|
||||
return Result.success(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
class FakePreferencesRepository(
|
||||
|
||||
@@ -6,9 +6,10 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.v2.runComposeUiTest
|
||||
@@ -16,12 +17,15 @@ import com.vnidrop.app.UiPlatform
|
||||
import com.vnidrop.app.core.DeviceRelationshipModel
|
||||
import com.vnidrop.app.core.DeviceRelationshipStateModel
|
||||
import com.vnidrop.app.core.PairingEligibilityModel
|
||||
import com.vnidrop.app.core.PendingTargetedOfferModel
|
||||
import com.vnidrop.app.core.SavedDeviceModel
|
||||
import com.vnidrop.app.core.TargetedTransferStateModel
|
||||
import com.vnidrop.app.ui.platform.LocalUiPlatform
|
||||
import com.vnidrop.app.ui.state.WindowClass
|
||||
import com.vnidrop.app.ui.theme.VniDropTheme
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.getString
|
||||
@@ -29,10 +33,15 @@ import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.button_retry
|
||||
import vnidrop.shared.generated.resources.saved_devices_block_action
|
||||
import vnidrop.shared.generated.resources.saved_devices_block_confirm_title
|
||||
import vnidrop.shared.generated.resources.saved_devices_attention_title
|
||||
import vnidrop.shared.generated.resources.saved_devices_devices_title
|
||||
import vnidrop.shared.generated.resources.saved_devices_empty
|
||||
import vnidrop.shared.generated.resources.saved_devices_empty_title
|
||||
import vnidrop.shared.generated.resources.saved_devices_forget_action
|
||||
import vnidrop.shared.generated.resources.saved_devices_load_failed
|
||||
import vnidrop.shared.generated.resources.saved_devices_send_action
|
||||
import vnidrop.shared.generated.resources.saved_devices_transfer_resume
|
||||
import vnidrop.shared.generated.resources.saved_devices_transfers_title
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class SavedDevicesScreenTest {
|
||||
@@ -53,7 +62,10 @@ class SavedDevicesScreenTest {
|
||||
runOnIdle { assertEquals(1, retried) }
|
||||
|
||||
runOnIdle { state.value = SavedDevicesState(isLoading = false) }
|
||||
onNodeWithText(Res.string.saved_devices_empty_title.value).assertIsDisplayed()
|
||||
onNodeWithText(Res.string.saved_devices_empty.value).assertIsDisplayed()
|
||||
onAllNodesWithText(Res.string.saved_devices_devices_title.value).assertCountEquals(0)
|
||||
onAllNodesWithText(Res.string.saved_devices_transfers_title.value).assertCountEquals(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -96,9 +108,10 @@ class SavedDevicesScreenTest {
|
||||
}
|
||||
}
|
||||
}
|
||||
onNodeWithText(Res.string.saved_devices_send_action.value).assertIsNotEnabled()
|
||||
onAllNodesWithContentDescription(Res.string.saved_devices_send_action.value).assertCountEquals(0)
|
||||
|
||||
runOnIdle { state.value = state.value.copy(busyPeerIds = emptySet()) }
|
||||
onNodeWithContentDescription(Res.string.saved_devices_send_action.value).assertIsDisplayed()
|
||||
onNodeWithContentDescription("More actions for Riley's phone").performClick()
|
||||
onNodeWithText(Res.string.saved_devices_block_action.value).performClick()
|
||||
onNodeWithText(Res.string.saved_devices_block_confirm_title.value).assertIsDisplayed()
|
||||
@@ -124,6 +137,106 @@ class SavedDevicesScreenTest {
|
||||
onNodeWithText(Res.string.saved_devices_forget_action.value).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun directTransferHistoryShowsProgressAndRoutesResumeById() = runComposeUiTest {
|
||||
val actions = mutableListOf<Pair<String, SavedDeviceTransferAction>>()
|
||||
setContent {
|
||||
CompositionLocalProvider(LocalUiPlatform provides UiPlatform.Android) {
|
||||
VniDropTheme(isDarkTheme = false) {
|
||||
SavedDevicesScreen(
|
||||
state = SavedDevicesState(
|
||||
isLoading = false,
|
||||
targetedTransfers = listOf(
|
||||
SavedDeviceTransferItem(
|
||||
id = "transfer-resume",
|
||||
peerEndpointId = "peer",
|
||||
peerDisplayName = "Office PC",
|
||||
direction = SavedDeviceTransferDirection.Incoming,
|
||||
transferName = "Project files",
|
||||
fileCount = 2u,
|
||||
totalSize = 100u,
|
||||
verifiedBytes = 40u,
|
||||
state = TargetedTransferStateModel.Interrupted,
|
||||
createdAt = 1,
|
||||
updatedAt = 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
windowClass = WindowClass.Desktop,
|
||||
onTransferAction = { id, action -> actions += id to action },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithText(Res.string.saved_devices_transfers_title.value).assertIsDisplayed()
|
||||
onNodeWithText("Project files").assertIsDisplayed()
|
||||
onNodeWithText("Office PC", substring = true).assertIsDisplayed()
|
||||
onNodeWithText(Res.string.saved_devices_transfer_resume.value).performClick()
|
||||
runOnIdle {
|
||||
assertEquals(listOf("transfer-resume" to SavedDeviceTransferAction.Resume), actions)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun desktopHubUsesGroupedDeviceListAndShowsInlineAttentionActions() = runComposeUiTest {
|
||||
val acceptedOffers = mutableListOf<String>()
|
||||
setContent {
|
||||
CompositionLocalProvider(LocalUiPlatform provides UiPlatform.Windows) {
|
||||
VniDropTheme(isDarkTheme = false) {
|
||||
SavedDevicesScreen(
|
||||
state = SavedDevicesState(
|
||||
isLoading = false,
|
||||
savedDevices = listOf(
|
||||
device("peer-one", null, "Amira's phone"),
|
||||
device("peer-two", "Studio PC", "Workstation"),
|
||||
),
|
||||
targetedOffers = TargetedOfferState(
|
||||
pending = listOf(offer("offer-one", "peer-one", "Holiday photos")),
|
||||
senderDisplayNames = mapOf("peer-one" to "Amira's phone"),
|
||||
),
|
||||
),
|
||||
windowClass = WindowClass.Desktop,
|
||||
onAcceptOffer = acceptedOffers::add,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithText(Res.string.saved_devices_devices_title.value).assertIsDisplayed()
|
||||
onNodeWithText(Res.string.saved_devices_attention_title.value).assertIsDisplayed()
|
||||
onNodeWithText("Amira's phone").assertIsDisplayed()
|
||||
onNodeWithText("Studio PC").assertIsDisplayed()
|
||||
onNodeWithTag("saved-device-peer-one").assertIsDisplayed()
|
||||
onNodeWithTag("saved-device-peer-two").assertIsDisplayed()
|
||||
onNodeWithText("Receive").performClick()
|
||||
runOnIdle { assertEquals(listOf("offer-one"), acceptedOffers) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun compactHubPlacesAttentionBeforeSavedDevices() = runComposeUiTest {
|
||||
setContent {
|
||||
CompositionLocalProvider(LocalUiPlatform provides UiPlatform.Android) {
|
||||
VniDropTheme(isDarkTheme = false) {
|
||||
SavedDevicesScreen(
|
||||
state = SavedDevicesState(
|
||||
isLoading = false,
|
||||
savedDevices = listOf(device("peer-one", null, "Phone")),
|
||||
eligibilities = listOf(eligibility("peer-two", "Laptop")),
|
||||
),
|
||||
windowClass = WindowClass.Phone,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val attentionTop = onNodeWithText(Res.string.saved_devices_attention_title.value)
|
||||
.fetchSemanticsNode().boundsInRoot.top
|
||||
val devicesTop = onNodeWithText(Res.string.saved_devices_devices_title.value)
|
||||
.fetchSemanticsNode().boundsInRoot.top
|
||||
assertTrue(attentionTop < devicesTop, "compact layouts should surface pending decisions before the device list")
|
||||
}
|
||||
|
||||
private fun device(endpoint: String, label: String?, name: String?) = SavedDeviceModel(
|
||||
endpointId = endpoint,
|
||||
localLabel = label,
|
||||
@@ -149,6 +262,19 @@ class SavedDevicesScreenTest {
|
||||
createdAt = 1,
|
||||
updatedAt = 2,
|
||||
)
|
||||
|
||||
private fun offer(id: String, sender: String, name: String) = PendingTargetedOfferModel(
|
||||
transferId = id,
|
||||
senderEndpointId = sender,
|
||||
receiverEndpointId = "local",
|
||||
manifestId = "manifest",
|
||||
contentHash = "hash",
|
||||
transferName = name,
|
||||
fileCount = 2u,
|
||||
totalSize = 100u,
|
||||
protocolVersion = 3u,
|
||||
receivedAt = 1,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -157,6 +283,8 @@ private fun SavedDevicesScreen(
|
||||
windowClass: WindowClass,
|
||||
onRetry: () -> Unit = {},
|
||||
onBlock: (String) -> Unit = {},
|
||||
onAcceptOffer: (String) -> Unit = {},
|
||||
onTransferAction: (String, SavedDeviceTransferAction) -> Unit = { _, _ -> },
|
||||
) = SavedDevicesScreen(
|
||||
state = state,
|
||||
windowClass = windowClass,
|
||||
@@ -165,10 +293,13 @@ private fun SavedDevicesScreen(
|
||||
onDeclineEligible = {},
|
||||
onAcceptIncoming = {},
|
||||
onDeclineIncoming = {},
|
||||
onAcceptOffer = onAcceptOffer,
|
||||
onDeclineOffer = {},
|
||||
onSend = {},
|
||||
onOpenLabel = {},
|
||||
onForget = {},
|
||||
onBlock = onBlock,
|
||||
onTransferAction = onTransferAction,
|
||||
onLabelDraftChanged = {},
|
||||
onSaveLabel = {},
|
||||
onClearLabel = {},
|
||||
|
||||
Reference in New Issue
Block a user