feat(shared): graduate saved devices experience

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

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 dapprobation</string>
<string name="status_approved">Approuvé</string>
<string name="status_connecting">Connexion</string>
<string name="status_interrupted">Interrompu</string>
<string name="status_transferring">Transfert</string>
<string name="status_declined">Refusé</string>
<string name="saved_devices_authenticated_name">Nom distant : %1$s</string>
<string name="saved_devices_block_confirm_body">Bloquer %1$s et refuser les futurs transferts dappareil enregistré ?</string>
<string name="saved_devices_block_confirm_title">Bloquer lappareil ?</string>
<string name="saved_devices_description">Envoyez directement aux appareils de confiance, sans partager une nouvelle invitation.</string>
<string name="saved_devices_endpoint">ID de lappareil : %1$s</string>
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
<string name="saved_devices_empty">Terminez un transfert, puis choisissez de mémoriser lautre appareil.</string>
<string name="saved_devices_empty_title">Aucun appareil enregistré</string>
<string name="saved_devices_forget_confirm_body">Oublier %1$s ? Vous devrez tous les deux approuver à nouveau lenregistrement avant un autre transfert direct.</string>
<string name="saved_devices_forget_confirm_title">Oublier lappareil ?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string>

View File

@@ -319,12 +319,32 @@
<string name="offer_body">%1$s vuole inviarti «%2$s».</string>
<string name="offer_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 laltro dispositivo.</string>
<string name="saved_devices_empty_title">Nessun dispositivo salvato</string>
<string name="saved_devices_forget_confirm_body">Dimenticare %1$s? Entrambi dovrete approvare nuovamente il salvataggio prima di un altro trasferimento diretto.</string>
<string name="saved_devices_forget_confirm_title">Dimenticare il dispositivo?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string>

View File

@@ -319,12 +319,32 @@
<string name="offer_body">%1$s wil je “%2$s” sturen.</string>
<string name="offer_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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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()

View File

@@ -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,

View File

@@ -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>
}

View File

@@ -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)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,6 +25,8 @@ internal fun SavedDevicesRoute(
onDeclineEligible = viewModel::declineEligible,
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,

View File

@@ -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) + ""

View File

@@ -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)

View File

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

View File

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

View File

@@ -1,8 +1,22 @@
package com.vnidrop.app.feature.saveddevices
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")
}
}

View File

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

View File

@@ -238,6 +238,11 @@ class FakeCoreGateway : CoreGateway {
val receivedTargetedTransferIds = mutableListOf<String>()
val 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(

View File

@@ -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 = {},