summaryrefslogtreecommitdiffhomepage
path: root/android/lib
diff options
context:
space:
mode:
authorDavid Göransson <david.goransson@mullvad.net>2025-07-04 16:12:32 +0200
committerDavid Göransson <david.goransson@mullvad.net>2025-07-04 16:12:32 +0200
commit5300f1663559ebd7a87c699db8e858d13e6fa556 (patch)
tree0081e14129def76d6a57b32232e42411c2fbe10d /android/lib
parent3e5795cbab6866fcd3eafee58d1790fc9e7f1829 (diff)
parent0d5660226494abaf04dc619997bf4d6a27c637d8 (diff)
downloadmullvadvpn-5300f1663559ebd7a87c699db8e858d13e6fa556.tar.xz
mullvadvpn-5300f1663559ebd7a87c699db8e858d13e6fa556.zip
Merge branch 'implement-new-select-location-design-droid-1954'
Diffstat (limited to 'android/lib')
-rw-r--r--android/lib/resource/src/main/res/values-da/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-de/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-es/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-fi/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-fr/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-it/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-ja/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-ko/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-my/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-nb/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-nl/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-pl/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-pt/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-ru/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-sv/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-th/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-tr/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-zh-rCN/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-zh-rTW/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values/strings.xml5
-rw-r--r--android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt2
-rw-r--r--android/lib/ui/component/build.gradle.kts11
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/ExpandChevron.kt70
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/CheckableRelayListItem.kt89
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayItemCheckableCellPreviewParameterProvider.kt28
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayItemPreviewData.kt65
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayListItem.kt132
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayListItemPreviewData.kt125
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/SelectableRelayListItem.kt206
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/SelectableRelayListItemPreviewParameterProvider.kt66
-rw-r--r--android/lib/ui/designsystem/build.gradle.kts43
-rw-r--r--android/lib/ui/designsystem/src/main/AndroidManifest.xml2
-rw-r--r--android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/Checkbox.kt56
-rw-r--r--android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/RelayListHeader.kt78
-rw-r--r--android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/RelayListItem.kt313
35 files changed, 1285 insertions, 44 deletions
diff --git a/android/lib/resource/src/main/res/values-da/strings.xml b/android/lib/resource/src/main/res/values-da/strings.xml
index abb07da226..48ed96072a 100644
--- a/android/lib/resource/src/main/res/values-da/strings.xml
+++ b/android/lib/resource/src/main/res/values-da/strings.xml
@@ -370,8 +370,6 @@
<string name="this_field_is_required">Dette felt er påkrævet</string>
<string name="this_is_already_set_as_current">Den er allerede indstillet som aktuel</string>
<string name="time_added">Tid tilføjet</string>
- <string name="to_add_locations_to_a_list">Tryk på \" ︙ \" eller tryk langvarigt på et land, en by eller en server for at tilføje placeringer til en liste.</string>
- <string name="to_create_a_custom_list">Tryk på \" ︙ \" for at oprette en brugerdefineret liste</string>
<string name="toggle_vpn">Slå VPN til/fra</string>
<string name="top_bar_device_name">Enhedsnavn: %1$s</string>
<string name="top_bar_time_left">Resterende tid: %1$s</string>
diff --git a/android/lib/resource/src/main/res/values-de/strings.xml b/android/lib/resource/src/main/res/values-de/strings.xml
index 37277ab1cf..3dcfcae8d8 100644
--- a/android/lib/resource/src/main/res/values-de/strings.xml
+++ b/android/lib/resource/src/main/res/values-de/strings.xml
@@ -370,8 +370,6 @@
<string name="this_field_is_required">Dieses Feld ist erforderlich</string>
<string name="this_is_already_set_as_current">Diese ist bereits die aktuelle</string>
<string name="time_added">Zeit hinzugefügt</string>
- <string name="to_add_locations_to_a_list">Um Standorte zu einer Liste hinzuzufügen, drücken Sie auf „︙“ oder drücken Sie lange auf ein Land, eine Stadt oder einen Server.</string>
- <string name="to_create_a_custom_list">Um eine eigene Liste zu erstellen, drücken Sie auf „︙“</string>
<string name="toggle_vpn">VPN umschalten</string>
<string name="top_bar_device_name">Gerätename: %1$s</string>
<string name="top_bar_time_left">Verbleibende Zeit: %1$s</string>
diff --git a/android/lib/resource/src/main/res/values-es/strings.xml b/android/lib/resource/src/main/res/values-es/strings.xml
index 6644043d86..b5b55248a8 100644
--- a/android/lib/resource/src/main/res/values-es/strings.xml
+++ b/android/lib/resource/src/main/res/values-es/strings.xml
@@ -370,8 +370,6 @@
<string name="this_field_is_required">Este campo es obligatorio</string>
<string name="this_is_already_set_as_current">Este ya está configurado como actual</string>
<string name="time_added">Tiempo añadido</string>
- <string name="to_add_locations_to_a_list">Para añadir ubicaciones a una lista, pulse «︙» o mantenga pulsado unos segundos un país, ciudad o servidor.</string>
- <string name="to_create_a_custom_list">Para crear una lista personalizada, pulse «︙»</string>
<string name="toggle_vpn">Alternar VPN</string>
<string name="top_bar_device_name">Nombre del dispositivo: %1$s</string>
<string name="top_bar_time_left">Tiempo restante: %1$s</string>
diff --git a/android/lib/resource/src/main/res/values-fi/strings.xml b/android/lib/resource/src/main/res/values-fi/strings.xml
index b81c2b8d6b..71b5abaa50 100644
--- a/android/lib/resource/src/main/res/values-fi/strings.xml
+++ b/android/lib/resource/src/main/res/values-fi/strings.xml
@@ -370,8 +370,6 @@
<string name="this_field_is_required">Tämä kenttä on pakollinen</string>
<string name="this_is_already_set_as_current">Tämä on jo asetettu nykyiseksi</string>
<string name="time_added">Käyttöaikaa lisätty</string>
- <string name="to_add_locations_to_a_list">Jos haluat lisätä luetteloon sijainteja, paina \"︙\" tai paina pitkään maata, kaupunkia tai palvelinta.</string>
- <string name="to_create_a_custom_list">Voit luoda mukautetun luettelon painamalla \"︙\"</string>
<string name="toggle_vpn">Vaihda VPN:ää</string>
<string name="top_bar_device_name">Laitteen nimi: %1$s</string>
<string name="top_bar_time_left">Aikaa jäljellä: %1$s</string>
diff --git a/android/lib/resource/src/main/res/values-fr/strings.xml b/android/lib/resource/src/main/res/values-fr/strings.xml
index a9299b359c..5eb05d1775 100644
--- a/android/lib/resource/src/main/res/values-fr/strings.xml
+++ b/android/lib/resource/src/main/res/values-fr/strings.xml
@@ -370,8 +370,6 @@
<string name="this_field_is_required">Ce champ est requis</string>
<string name="this_is_already_set_as_current">Déjà définie comme méthode actuelle</string>
<string name="time_added">Temps ajouté</string>
- <string name="to_add_locations_to_a_list">Pour ajouter des localisations à une liste, appuyez sur la touche « ︙ » ou appuyez longuement sur un pays, une ville ou un serveur.</string>
- <string name="to_create_a_custom_list">Appuyez sur « ︙ » pour créer une liste personnalisée</string>
<string name="toggle_vpn">Activer/désactiver le VPN</string>
<string name="top_bar_device_name">Nom de l\'appareil : %1$s</string>
<string name="top_bar_time_left">Temps restant : %1$s</string>
diff --git a/android/lib/resource/src/main/res/values-it/strings.xml b/android/lib/resource/src/main/res/values-it/strings.xml
index b17d8d64f8..84a4bd347a 100644
--- a/android/lib/resource/src/main/res/values-it/strings.xml
+++ b/android/lib/resource/src/main/res/values-it/strings.xml
@@ -370,8 +370,6 @@
<string name="this_field_is_required">Questo campo è obbligatorio</string>
<string name="this_is_already_set_as_current">È già impostato come attuale</string>
<string name="time_added">Tempo aggiunto</string>
- <string name="to_add_locations_to_a_list">Per aggiungere posizioni a un elenco, premi \"︙\" o tieni premuto su un Paese, una città o un server.</string>
- <string name="to_create_a_custom_list">Per creare un elenco personalizzato, premi \"︙\"</string>
<string name="toggle_vpn">Attiva/disattiva VPN</string>
<string name="top_bar_device_name">Nome del dispositivo: %1$s</string>
<string name="top_bar_time_left">Tempo rimasto: %1$s</string>
diff --git a/android/lib/resource/src/main/res/values-ja/strings.xml b/android/lib/resource/src/main/res/values-ja/strings.xml
index d90c551c36..9c623768af 100644
--- a/android/lib/resource/src/main/res/values-ja/strings.xml
+++ b/android/lib/resource/src/main/res/values-ja/strings.xml
@@ -370,8 +370,6 @@
<string name="this_field_is_required">このフィールドは必須です</string>
<string name="this_is_already_set_as_current">これはすでに現在の設定として設定されています</string>
<string name="time_added">時間が追加されました</string>
- <string name="to_add_locations_to_a_list">リストに場所を追加するには、\"︙\" を押すか、または、国、都市、サーバーを長押ししてください。</string>
- <string name="to_create_a_custom_list">カスタムリストを作成するには、\"︙\" を押してください</string>
<string name="toggle_vpn">VPNの切り替え</string>
<string name="top_bar_device_name">デバイス名: %1$s</string>
<string name="top_bar_time_left">残り時間: %1$s</string>
diff --git a/android/lib/resource/src/main/res/values-ko/strings.xml b/android/lib/resource/src/main/res/values-ko/strings.xml
index e47b9d5ed7..1e4e0950e5 100644
--- a/android/lib/resource/src/main/res/values-ko/strings.xml
+++ b/android/lib/resource/src/main/res/values-ko/strings.xml
@@ -370,8 +370,6 @@
<string name="this_field_is_required">이 필드는 필수입니다</string>
<string name="this_is_already_set_as_current">이미 현재로 설정되어 있습니다</string>
<string name="time_added">시간 추가됨</string>
- <string name="to_add_locations_to_a_list">목록에 위치를 추가하려면 \"︙\"를 누르거나 국가, 도시 또는 서버를 길게 누릅니다.</string>
- <string name="to_create_a_custom_list">사용자 지정 목록을 생성하려면 \"︙\"를 누릅니다</string>
<string name="toggle_vpn">VPN 전환</string>
<string name="top_bar_device_name">장치 이름: %1$s</string>
<string name="top_bar_time_left">남은 시간: %1$s</string>
diff --git a/android/lib/resource/src/main/res/values-my/strings.xml b/android/lib/resource/src/main/res/values-my/strings.xml
index 252426d735..deeb595a88 100644
--- a/android/lib/resource/src/main/res/values-my/strings.xml
+++ b/android/lib/resource/src/main/res/values-my/strings.xml
@@ -370,8 +370,6 @@
<string name="this_field_is_required">ဤအကွက်ကို မဖြစ်မနေဖြည့်ရမည်</string>
<string name="this_is_already_set_as_current">၎င်းကို လက်ရှိအဖြစ် သတ်မှတ်ထားပြီးပါပြီ</string>
<string name="time_added">အချိန်တိုးထားသည်</string>
- <string name="to_add_locations_to_a_list">စာရင်းထဲသို့ တည်နေရာများကို ပေါင်းထည့်ရန် \"︙\" ကို နှိပ်ပါ သို့မဟုတ် နိုင်ငံ၊ မြို့၊ ဆာဗာကို နှိပ်ပါ။</string>
- <string name="to_create_a_custom_list">စိတ်ကြိုက် စာရင်းများကို ဖန်တီးရန် \"︙\" ကို နှိပ်ပါ</string>
<string name="toggle_vpn">VPN ရွေးသုံးရန်</string>
<string name="top_bar_device_name">စက်အမည်- %1$s</string>
<string name="top_bar_time_left">ကျန်သည့် အချိန်- %1$s</string>
diff --git a/android/lib/resource/src/main/res/values-nb/strings.xml b/android/lib/resource/src/main/res/values-nb/strings.xml
index 310f0bfabf..5d9fe91901 100644
--- a/android/lib/resource/src/main/res/values-nb/strings.xml
+++ b/android/lib/resource/src/main/res/values-nb/strings.xml
@@ -370,8 +370,6 @@
<string name="this_field_is_required">Feltet er påkrevd</string>
<string name="this_is_already_set_as_current">Denne er allerede angitt som gjeldende</string>
<string name="time_added">Tid lagt til</string>
- <string name="to_add_locations_to_a_list">Hvis du vil legge til plasseringer i en liste, trykker du på ︙ eller trykker på og holder inne et land, en by eller en server.</string>
- <string name="to_create_a_custom_list">For å opprette en egendefinert liste trykker du på ︙</string>
<string name="toggle_vpn">Velg VPN</string>
<string name="top_bar_device_name">Enhetsnavn: %1$s</string>
<string name="top_bar_time_left">Tid igjen: %1$s</string>
diff --git a/android/lib/resource/src/main/res/values-nl/strings.xml b/android/lib/resource/src/main/res/values-nl/strings.xml
index 472ea9f1ab..4687d40208 100644
--- a/android/lib/resource/src/main/res/values-nl/strings.xml
+++ b/android/lib/resource/src/main/res/values-nl/strings.xml
@@ -370,8 +370,6 @@
<string name="this_field_is_required">Dit veld is verplicht</string>
<string name="this_is_already_set_as_current">Deze is al ingesteld als huidige</string>
<string name="time_added">Tijd van toevoegen</string>
- <string name="to_add_locations_to_a_list">Druk op de \"︙\" of druk lang op een land, plaats of server om locaties toe te voegen.</string>
- <string name="to_create_a_custom_list">Druk op de \"︙\" om een aangepaste lijst te maken</string>
<string name="toggle_vpn">VPN in-/uitschakelen</string>
<string name="top_bar_device_name">Apparaatnaam: %1$s</string>
<string name="top_bar_time_left">Resterende tijd: %1$s</string>
diff --git a/android/lib/resource/src/main/res/values-pl/strings.xml b/android/lib/resource/src/main/res/values-pl/strings.xml
index 76a26b0ec9..fb344a0998 100644
--- a/android/lib/resource/src/main/res/values-pl/strings.xml
+++ b/android/lib/resource/src/main/res/values-pl/strings.xml
@@ -370,8 +370,6 @@
<string name="this_field_is_required">To pole jest wymagane</string>
<string name="this_is_already_set_as_current">Już ustawiona jako bieżąca</string>
<string name="time_added">Dodano czas</string>
- <string name="to_add_locations_to_a_list">Aby dodać lokalizacje do listy, naciśnij przycisk „︙” lub naciśnij i przytrzymaj kraj, miasto albo serwer.</string>
- <string name="to_create_a_custom_list">Aby utworzyć listę niestandardową, naciśnij przycisk „︙”</string>
<string name="toggle_vpn">Przełącz VPN</string>
<string name="top_bar_device_name">Nazwa urządzenia: %1$s</string>
<string name="top_bar_time_left">Pozostało: %1$s</string>
diff --git a/android/lib/resource/src/main/res/values-pt/strings.xml b/android/lib/resource/src/main/res/values-pt/strings.xml
index 39727d780c..dd3b3be340 100644
--- a/android/lib/resource/src/main/res/values-pt/strings.xml
+++ b/android/lib/resource/src/main/res/values-pt/strings.xml
@@ -370,8 +370,6 @@
<string name="this_field_is_required">Este campo é obrigatório</string>
<string name="this_is_already_set_as_current">Este já está definido como o atual</string>
<string name="time_added">Tempo adicionado</string>
- <string name="to_add_locations_to_a_list">Para adicionar localizações a uma lista, prima o botão \"︙\" ou mantenha premido um país, uma cidade ou um servidor.</string>
- <string name="to_create_a_custom_list">Para criar uma lista personalizada prima o botão \"︙\"</string>
<string name="toggle_vpn">Alternar VPN</string>
<string name="top_bar_device_name">Nome do dispositivo: %1$s</string>
<string name="top_bar_time_left">Tempo restante: %1$s</string>
diff --git a/android/lib/resource/src/main/res/values-ru/strings.xml b/android/lib/resource/src/main/res/values-ru/strings.xml
index aa7785dc7b..4c57b0817a 100644
--- a/android/lib/resource/src/main/res/values-ru/strings.xml
+++ b/android/lib/resource/src/main/res/values-ru/strings.xml
@@ -370,8 +370,6 @@
<string name="this_field_is_required">Это обязательное поле</string>
<string name="this_is_already_set_as_current">Этот метод уже установлен как текущий</string>
<string name="time_added">Время добавлено</string>
- <string name="to_add_locations_to_a_list">Чтобы добавить местоположения в список, нажмите «︙» или нажмите и удерживайте страну, город или сервер.</string>
- <string name="to_create_a_custom_list">Чтобы создать свой список, нажмите «︙»</string>
<string name="toggle_vpn">Включение VPN</string>
<string name="top_bar_device_name">Имя устройства: %1$s</string>
<string name="top_bar_time_left">Осталось времени: %1$s</string>
diff --git a/android/lib/resource/src/main/res/values-sv/strings.xml b/android/lib/resource/src/main/res/values-sv/strings.xml
index 732e8a11ed..93a98f6874 100644
--- a/android/lib/resource/src/main/res/values-sv/strings.xml
+++ b/android/lib/resource/src/main/res/values-sv/strings.xml
@@ -370,8 +370,6 @@
<string name="this_field_is_required">Fältet är obligatoriskt</string>
<string name="this_is_already_set_as_current">Den har redan ställts in som aktuell</string>
<string name="time_added">Tid har lagts till</string>
- <string name="to_add_locations_to_a_list">Om du vill lägga till platser i en lista kan du trycka på \"︙\" eller trycka länge på ett land, en stad eller en server.</string>
- <string name="to_create_a_custom_list">Om du vill skapa en anpassad lista trycker du på \"︙\"</string>
<string name="toggle_vpn">Växla VPN</string>
<string name="top_bar_device_name">Enhetsnamn: %1$s</string>
<string name="top_bar_time_left">Tid kvar: %1$s</string>
diff --git a/android/lib/resource/src/main/res/values-th/strings.xml b/android/lib/resource/src/main/res/values-th/strings.xml
index 598334218a..2bbca2a79b 100644
--- a/android/lib/resource/src/main/res/values-th/strings.xml
+++ b/android/lib/resource/src/main/res/values-th/strings.xml
@@ -370,8 +370,6 @@
<string name="this_field_is_required">จำเป็นต้องกรอกช่องนี้</string>
<string name="this_is_already_set_as_current">นี่ได้รับการตั้งค่าเป็นปัจจุบันแล้ว</string>
<string name="time_added">เพิ่มเวลาแล้ว</string>
- <string name="to_add_locations_to_a_list">กด \"︙\" หรือกดประเทศ เมือง หรือเซิร์ฟเวอร์ค้างไว้ เพื่อเพิ่มตำแหน่งที่ตั้งลงในรายการ</string>
- <string name="to_create_a_custom_list">กด \"︙\" เพื่อสร้างรายการแบบกำหนดเอง</string>
<string name="toggle_vpn">เปิด/ปิด VPN</string>
<string name="top_bar_device_name">ชื่ออุปกรณ์: %1$s</string>
<string name="top_bar_time_left">เหลือเวลา: %1$s</string>
diff --git a/android/lib/resource/src/main/res/values-tr/strings.xml b/android/lib/resource/src/main/res/values-tr/strings.xml
index 0c7cbace17..038f87f4d8 100644
--- a/android/lib/resource/src/main/res/values-tr/strings.xml
+++ b/android/lib/resource/src/main/res/values-tr/strings.xml
@@ -370,8 +370,6 @@
<string name="this_field_is_required">Bu alan gereklidir</string>
<string name="this_is_already_set_as_current">Bu, zaten geçerli olarak ayarlı yöntemdir</string>
<string name="time_added">Süre eklendi</string>
- <string name="to_add_locations_to_a_list">Listeye konum eklemek için \"︙\" düğmesine basın veya bir ülke, şehir veya sunucunun üzerine uzun basın.</string>
- <string name="to_create_a_custom_list">Özel bir liste oluşturmak için \"︙\" düğmesine basın</string>
<string name="toggle_vpn">VPN\'i aç/kapat</string>
<string name="top_bar_device_name">Cihaz adı: %1$s</string>
<string name="top_bar_time_left">Kalan süre: %1$s</string>
diff --git a/android/lib/resource/src/main/res/values-zh-rCN/strings.xml b/android/lib/resource/src/main/res/values-zh-rCN/strings.xml
index 55e749d49c..fbf050b7b8 100644
--- a/android/lib/resource/src/main/res/values-zh-rCN/strings.xml
+++ b/android/lib/resource/src/main/res/values-zh-rCN/strings.xml
@@ -370,8 +370,6 @@
<string name="this_field_is_required">此字段为必填项</string>
<string name="this_is_already_set_as_current">它已被设置为当前方法</string>
<string name="time_added">时间已添加</string>
- <string name="to_add_locations_to_a_list">要将位置添加到列表中,请按“︙”或长按国家/地区、城市或服务器。</string>
- <string name="to_create_a_custom_list">要创建自定义列表,请按“︙”</string>
<string name="toggle_vpn">切换 VPN</string>
<string name="top_bar_device_name">设备名称:%1$s</string>
<string name="top_bar_time_left">剩余时间:%1$s</string>
diff --git a/android/lib/resource/src/main/res/values-zh-rTW/strings.xml b/android/lib/resource/src/main/res/values-zh-rTW/strings.xml
index edfd7b2d02..be4e67c35c 100644
--- a/android/lib/resource/src/main/res/values-zh-rTW/strings.xml
+++ b/android/lib/resource/src/main/res/values-zh-rTW/strings.xml
@@ -370,8 +370,6 @@
<string name="this_field_is_required">此欄位為必填項</string>
<string name="this_is_already_set_as_current">已將其設定為目前方式</string>
<string name="time_added">已增加時間</string>
- <string name="to_add_locations_to_a_list">若要在清單中新增位置,請按下「︙」,或是長按下國家/地區、城市或伺服器。</string>
- <string name="to_create_a_custom_list">若要建立自訂清單,請按下「︙」</string>
<string name="toggle_vpn">切換 VPN</string>
<string name="top_bar_device_name">裝置名稱:%1$s</string>
<string name="top_bar_time_left">剩餘時間:%1$s</string>
diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml
index 09cd1ebd99..fd01cfbcd6 100644
--- a/android/lib/resource/src/main/res/values/strings.xml
+++ b/android/lib/resource/src/main/res/values/strings.xml
@@ -259,9 +259,9 @@
<string name="custom_list_error_list_exists">Name is already taken.</string>
<string name="update_list_name">Update list name</string>
<string name="no_custom_lists_available">No custom lists available</string>
- <string name="to_create_a_custom_list">To create a custom list press the \"︙\"</string>
+ <string name="to_create_a_custom_list">To create a custom list press the \"+\"</string>
<string name="new_list">New list</string>
- <string name="to_add_locations_to_a_list">To add locations to a list, press the \"︙\" or long press on a country, city, or server.</string>
+ <string name="to_add_locations_to_a_list">To add locations to a list, press the pen or long press on a country, city, or server.</string>
<string name="edit_list">Edit list</string>
<string name="delete">Delete</string>
<string name="delete_custom_list_message">\"%s\" was deleted</string>
@@ -426,4 +426,5 @@
<string name="time_added">Time added</string>
<string name="app_is_blocking_internet">The app is blocking internet, please disconnect first</string>
<string name="in_app_products_unavailable">In-app products unavailable, please make sure you have the latest version of Google Play.</string>
+ <string name="relayitem_is_inactive">%s is unavailable</string>
</resources>
diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt
index 0d40e7c805..5179b5c306 100644
--- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt
+++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt
@@ -59,6 +59,8 @@ data class Dimensions(
val problemReportIconToTitlePadding: Dp = 60.dp,
val reconnectButtonMinInteractiveComponentSize: Dp = 40.dp,
val relayCircleSize: Dp = 16.dp,
+ val relayCirclePadding: Dp = 6.dp,
+ val relayItemCornerRadius: Dp = 16.dp,
val screenBottomMargin: Dp = 16.dp,
val screenTopMargin: Dp = 24.dp,
val searchFieldHeight: Dp = 42.dp,
diff --git a/android/lib/ui/component/build.gradle.kts b/android/lib/ui/component/build.gradle.kts
index 8ca10e29c5..56ec9a63ef 100644
--- a/android/lib/ui/component/build.gradle.kts
+++ b/android/lib/ui/component/build.gradle.kts
@@ -35,15 +35,18 @@ android {
}
dependencies {
+ implementation(projects.lib.model)
+ implementation(projects.lib.resource)
+ implementation(projects.lib.theme)
implementation(projects.lib.ui.tag)
+ implementation(projects.lib.ui.designsystem)
+
implementation(libs.compose.material3)
implementation(libs.compose.ui)
+ implementation(libs.compose.ui.tooling)
+ implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.constrainlayout)
implementation(libs.kotlin.stdlib)
implementation(libs.compose.icons.extended)
implementation(libs.androidx.ktx)
- implementation(projects.lib.resource)
- implementation(projects.lib.shared)
- implementation(projects.lib.theme)
- implementation(projects.lib.model)
}
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/ExpandChevron.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/ExpandChevron.kt
new file mode 100644
index 0000000000..5c5eed486e
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/ExpandChevron.kt
@@ -0,0 +1,70 @@
+package net.mullvad.mullvadvpn.lib.ui.component
+
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.TweenSpec
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+
+@Composable
+@Preview
+private fun PreviewChevron() {
+ AppTheme {
+ Surface {
+ Column {
+ ExpandChevron(isExpanded = false)
+ ExpandChevron(isExpanded = true)
+ }
+ }
+ }
+}
+
+@Composable
+fun ExpandChevron(modifier: Modifier = Modifier, isExpanded: Boolean) {
+ val degree = remember(isExpanded) { if (isExpanded) UP_ROTATION else DOWN_ROTATION }
+ val stateLabel =
+ if (isExpanded) {
+ stringResource(id = R.string.collapse)
+ } else {
+ stringResource(id = R.string.expand)
+ }
+ val animatedRotation =
+ animateFloatAsState(
+ targetValue = degree,
+ label = "",
+ animationSpec = TweenSpec(ROTATION_ANIMATION_DURATION, easing = LinearEasing),
+ )
+
+ Icon(
+ imageVector = Icons.Default.KeyboardArrowDown,
+ contentDescription = stateLabel,
+ // tint = color,
+ modifier = modifier.rotate(animatedRotation.value),
+ )
+}
+
+@Composable
+fun ExpandChevronIconButton(
+ modifier: Modifier = Modifier,
+ onExpand: (Boolean) -> Unit,
+ isExpanded: Boolean,
+) {
+ IconButton(modifier = modifier, onClick = { onExpand(!isExpanded) }) {
+ ExpandChevron(isExpanded = isExpanded)
+ }
+}
+
+private const val DOWN_ROTATION = 0f
+private const val UP_ROTATION = 180f
+private const val ROTATION_ANIMATION_DURATION = 100
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/CheckableRelayListItem.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/CheckableRelayListItem.kt
new file mode 100644
index 0000000000..d92e978d5c
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/CheckableRelayListItem.kt
@@ -0,0 +1,89 @@
+package net.mullvad.mullvadvpn.lib.ui.component.relaylist
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.times
+import net.mullvad.mullvadvpn.lib.model.RelayItem
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.lib.ui.component.ExpandChevron
+import net.mullvad.mullvadvpn.lib.ui.designsystem.Checkbox
+import net.mullvad.mullvadvpn.lib.ui.designsystem.RelayListItem
+import net.mullvad.mullvadvpn.lib.ui.designsystem.RelayListItemDefaults
+import net.mullvad.mullvadvpn.lib.ui.tag.EXPAND_BUTTON_TEST_TAG
+import net.mullvad.mullvadvpn.lib.ui.tag.LOCATION_CELL_TEST_TAG
+
+@Composable
+@Preview
+private fun PreviewCheckableRelayLocationCell(
+ @PreviewParameter(RelayItemCheckableCellPreviewParameterProvider::class)
+ relayItems: List<RelayItem.Location.Country>
+) {
+ AppTheme {
+ Column(Modifier.background(color = MaterialTheme.colorScheme.surface)) {
+ relayItems.map {
+ Spacer(Modifier.size(1.dp))
+ CheckableRelayLocationCell(
+ item = CheckableRelayListItem(item = it, itemPosition = ItemPosition.Single),
+ onExpand = {},
+ modifier = Modifier.testTag(LOCATION_CELL_TEST_TAG),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun CheckableRelayLocationCell(
+ item: CheckableRelayListItem,
+ modifier: Modifier = Modifier,
+ onRelayCheckedChange: (isChecked: Boolean) -> Unit = { _ -> },
+ onExpand: (Boolean) -> Unit,
+) {
+ RelayListItem(
+ modifier = modifier.clip(itemPosition = item.itemPosition),
+ selected = false,
+ content = {
+ Row(
+ modifier =
+ Modifier.padding(start = item.depth * Dimens.mediumPadding)
+ .padding(Dimens.mediumPadding),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Name(name = item.item.name, state = null, active = true)
+ }
+ },
+ leadingContent = {
+ Checkbox(checked = item.checked, onCheckedChange = onRelayCheckedChange)
+ },
+ onClick = { onRelayCheckedChange(!item.checked) },
+ onLongClick = null,
+ trailingContent = {
+ if (item.item.hasChildren) {
+ ExpandChevron(
+ isExpanded = item.expanded,
+ modifier =
+ Modifier.clickable { onExpand(!item.expanded) }
+ .fillMaxSize()
+ .padding(Dimens.mediumPadding)
+ .testTag(EXPAND_BUTTON_TEST_TAG),
+ )
+ }
+ },
+ colors = RelayListItemDefaults.colors(containerColor = item.depth.toBackgroundColor()),
+ )
+}
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayItemCheckableCellPreviewParameterProvider.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayItemCheckableCellPreviewParameterProvider.kt
new file mode 100644
index 0000000000..918203db53
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayItemCheckableCellPreviewParameterProvider.kt
@@ -0,0 +1,28 @@
+package net.mullvad.mullvadvpn.lib.ui.component.relaylist
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import net.mullvad.mullvadvpn.lib.model.RelayItem
+
+class RelayItemCheckableCellPreviewParameterProvider :
+ PreviewParameterProvider<List<RelayItem.Location.Country>> {
+ override val values =
+ sequenceOf(
+ listOf(
+ generateRelayItemCountry(
+ name = "Relay country Active",
+ cityNames = listOf("Relay city 1", "Relay city 2"),
+ relaysPerCity = 2,
+ ),
+ generateRelayItemCountry(
+ name = "Relay country Expanded",
+ cityNames = listOf("Normal city"),
+ relaysPerCity = 2,
+ ),
+ generateRelayItemCountry(
+ name = "Country and city Expanded",
+ cityNames = listOf("Expanded city A", "Expanded city B"),
+ relaysPerCity = 2,
+ ),
+ )
+ )
+}
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayItemPreviewData.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayItemPreviewData.kt
new file mode 100644
index 0000000000..35397a6a27
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayItemPreviewData.kt
@@ -0,0 +1,65 @@
+package net.mullvad.mullvadvpn.lib.ui.component.relaylist
+
+import net.mullvad.mullvadvpn.lib.model.GeoLocationId
+import net.mullvad.mullvadvpn.lib.model.Ownership
+import net.mullvad.mullvadvpn.lib.model.ProviderId
+import net.mullvad.mullvadvpn.lib.model.RelayItem
+
+fun generateRelayItemCountry(
+ name: String,
+ cityNames: List<String>,
+ relaysPerCity: Int,
+ active: Boolean = true,
+) =
+ RelayItem.Location.Country(
+ name = name,
+ id = name.generateCountryCode(),
+ cities =
+ cityNames.map { cityName ->
+ generateRelayItemCity(cityName, name.generateCountryCode(), relaysPerCity, active)
+ },
+ )
+
+private fun generateRelayItemCity(
+ name: String,
+ countryCode: GeoLocationId.Country,
+ numberOfRelays: Int,
+ active: Boolean = true,
+) =
+ RelayItem.Location.City(
+ name = name,
+ id = name.generateCityCode(countryCode),
+ relays =
+ List(numberOfRelays) { index ->
+ generateRelayItemRelay(
+ name.generateCityCode(countryCode),
+ generateHostname(name.generateCityCode(countryCode), index),
+ active,
+ )
+ },
+ )
+
+private fun generateRelayItemRelay(
+ cityCode: GeoLocationId.City,
+ hostName: String,
+ active: Boolean = true,
+ daita: Boolean = true,
+) =
+ RelayItem.Location.Relay(
+ id = GeoLocationId.Hostname(city = cityCode, code = hostName),
+ active = active,
+ provider = ProviderId("Provider"),
+ ownership = Ownership.MullvadOwned,
+ daita = daita,
+ )
+
+private fun String.generateCountryCode() =
+ GeoLocationId.Country((take(1) + takeLast(1)).lowercase())
+
+private fun String.generateCityCode(countryCode: GeoLocationId.Country) =
+ GeoLocationId.City(countryCode, take(CITY_CODE_LENGTH).lowercase())
+
+private fun generateHostname(city: GeoLocationId.City, index: Int) =
+ "${city.country.code}-${city.code}-wg-${index+1}"
+
+private const val CITY_CODE_LENGTH = 3
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayListItem.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayListItem.kt
new file mode 100644
index 0000000000..8132a9ece7
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayListItem.kt
@@ -0,0 +1,132 @@
+package net.mullvad.mullvadvpn.lib.ui.component.relaylist
+
+import net.mullvad.mullvadvpn.lib.model.CustomListId
+import net.mullvad.mullvadvpn.lib.model.CustomListName
+import net.mullvad.mullvadvpn.lib.model.RelayItem
+
+enum class RelayListItemContentType {
+ CUSTOM_LIST_HEADER,
+ CUSTOM_LIST_ITEM,
+ CUSTOM_LIST_ENTRY_ITEM,
+ CUSTOM_LIST_FOOTER,
+ LOCATION_HEADER,
+ LOCATION_ITEM,
+ LOCATIONS_EMPTY_TEXT,
+ EMPTY_RELAY_LIST,
+}
+
+enum class RelayListItemState {
+ USED_AS_ENTRY,
+ USED_AS_EXIT,
+}
+
+sealed interface RelayListItem {
+ val key: Any
+ val contentType: RelayListItemContentType
+
+ data object CustomListHeader : RelayListItem {
+ override val key = "custom_list_header"
+ override val contentType = RelayListItemContentType.CUSTOM_LIST_HEADER
+ }
+
+ sealed interface SelectableItem : RelayListItem {
+ val item: RelayItem
+ val depth: Int
+ val isSelected: Boolean
+ val expanded: Boolean
+ val state: RelayListItemState?
+ val itemPosition: ItemPosition
+ }
+
+ data class CustomListItem(
+ override val item: RelayItem.CustomList,
+ override val isSelected: Boolean = false,
+ override val expanded: Boolean = false,
+ override val state: RelayListItemState? = null,
+ override val itemPosition: ItemPosition = ItemPosition.Single,
+ ) : SelectableItem {
+ override val key = item.id
+ override val depth: Int = 0
+ override val contentType = RelayListItemContentType.CUSTOM_LIST_ITEM
+ }
+
+ data class CustomListEntryItem(
+ val parentId: CustomListId,
+ val parentName: CustomListName,
+ override val item: RelayItem.Location,
+ override val expanded: Boolean,
+ override val depth: Int = 0,
+ override val state: RelayListItemState? = null,
+ override val itemPosition: ItemPosition,
+ ) : SelectableItem {
+ override val key = parentId to item.id
+
+ // Can't be displayed as selected
+ override val isSelected: Boolean = false
+ override val contentType = RelayListItemContentType.CUSTOM_LIST_ENTRY_ITEM
+ }
+
+ data class CustomListFooter(val hasCustomList: Boolean) : RelayListItem {
+ override val key = "custom_list_footer"
+ override val contentType = RelayListItemContentType.CUSTOM_LIST_FOOTER
+ }
+
+ data object LocationHeader : RelayListItem {
+ override val key = "location_header"
+ override val contentType = RelayListItemContentType.LOCATION_HEADER
+ }
+
+ data class GeoLocationItem(
+ override val item: RelayItem.Location,
+ override val isSelected: Boolean = false,
+ override val depth: Int = 0,
+ override val expanded: Boolean = false,
+ override val state: RelayListItemState? = null,
+ override val itemPosition: ItemPosition,
+ ) : SelectableItem {
+ override val key = item.id
+ override val contentType = RelayListItemContentType.LOCATION_ITEM
+ }
+
+ data class LocationsEmptyText(val searchTerm: String) : RelayListItem {
+ override val key = "locations_empty_text"
+ override val contentType = RelayListItemContentType.LOCATIONS_EMPTY_TEXT
+ }
+
+ data object EmptyRelayList : RelayListItem {
+ override val key = "empty_relay_list"
+ override val contentType = RelayListItemContentType.EMPTY_RELAY_LIST
+ }
+}
+
+data class CheckableRelayListItem(
+ val item: RelayItem.Location,
+ val depth: Int = 0,
+ val checked: Boolean = false,
+ val expanded: Boolean = false,
+ val itemPosition: ItemPosition = ItemPosition.Single,
+)
+
+sealed interface ItemPosition {
+ data object Top : ItemPosition
+
+ data object Middle : ItemPosition
+
+ data object Bottom : ItemPosition
+
+ data object Single : ItemPosition
+
+ fun roundTop(): Boolean =
+ when (this) {
+ is Single,
+ Top -> true
+ else -> false
+ }
+
+ fun roundBottom(): Boolean =
+ when (this) {
+ is Single,
+ Bottom -> true
+ else -> false
+ }
+}
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayListItemPreviewData.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayListItemPreviewData.kt
new file mode 100644
index 0000000000..5776601168
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayListItemPreviewData.kt
@@ -0,0 +1,125 @@
+package net.mullvad.mullvadvpn.lib.ui.component.relaylist
+
+import net.mullvad.mullvadvpn.lib.model.CustomList
+import net.mullvad.mullvadvpn.lib.model.CustomListId
+import net.mullvad.mullvadvpn.lib.model.CustomListName
+import net.mullvad.mullvadvpn.lib.model.RelayItem
+
+object RelayListItemPreviewData {
+ @Suppress("LongMethod")
+ fun generateRelayListItems(
+ includeCustomLists: Boolean,
+ isSearching: Boolean,
+ ): List<RelayListItem> = buildList {
+ if (!isSearching || includeCustomLists) {
+ add(RelayListItem.CustomListHeader)
+ // Add custom list items
+ if (includeCustomLists) {
+ RelayListItem.CustomListItem(
+ item =
+ RelayItem.CustomList(
+ customList =
+ CustomList(
+ id = CustomListId("custom_list_id"),
+ name = CustomListName.fromString("Custom List"),
+ locations = emptyList(),
+ ),
+ locations =
+ listOf(
+ generateRelayItemCountry(
+ name = "Country",
+ cityNames = listOf("City"),
+ relaysPerCity = 2,
+ active = true,
+ )
+ ),
+ ),
+ isSelected = false,
+ state = null,
+ expanded = false,
+ itemPosition = ItemPosition.Single,
+ )
+ }
+ if (!isSearching) {
+ add(RelayListItem.CustomListFooter(hasCustomList = includeCustomLists))
+ }
+ }
+ add(RelayListItem.LocationHeader)
+ val locations =
+ listOf(
+ generateRelayItemCountry(
+ name = "First Country",
+ cityNames = listOf("Capital City", "Minor City"),
+ relaysPerCity = 2,
+ active = true,
+ ),
+ generateRelayItemCountry(
+ name = "Second Country",
+ cityNames = listOf("Medium City", "Small City", "Vivec City"),
+ relaysPerCity = 1,
+ active = false,
+ ),
+ )
+ addAll(
+ listOf(
+ RelayListItem.GeoLocationItem(
+ item = locations[0],
+ isSelected = false,
+ depth = 0,
+ expanded = true,
+ state = null,
+ itemPosition = ItemPosition.Middle,
+ ),
+ RelayListItem.GeoLocationItem(
+ item = locations[0].cities[0],
+ isSelected = true,
+ depth = 1,
+ expanded = false,
+ state = null,
+ itemPosition = ItemPosition.Middle,
+ ),
+ RelayListItem.GeoLocationItem(
+ item = locations[0].cities[1],
+ isSelected = false,
+ depth = 1,
+ expanded = true,
+ state = null,
+ itemPosition = ItemPosition.Middle,
+ ),
+ RelayListItem.GeoLocationItem(
+ item = locations[0].cities[1].relays[0],
+ isSelected = false,
+ depth = 2,
+ expanded = false,
+ state = RelayListItemState.USED_AS_EXIT,
+ itemPosition = ItemPosition.Middle,
+ ),
+ RelayListItem.GeoLocationItem(
+ item = locations[0].cities[1].relays[1],
+ isSelected = false,
+ depth = 2,
+ expanded = false,
+ state = null,
+ itemPosition = ItemPosition.Middle,
+ ),
+ RelayListItem.GeoLocationItem(
+ item = locations[1],
+ isSelected = false,
+ depth = 0,
+ expanded = false,
+ state = null,
+ itemPosition = ItemPosition.Bottom,
+ ),
+ )
+ )
+ }
+
+ fun generateEmptyList(searchTerm: String, isSearching: Boolean) =
+ listOf(
+ if (isSearching) {
+ RelayListItem.LocationsEmptyText(searchTerm)
+ } else {
+ RelayListItem.EmptyRelayList
+ }
+ )
+}
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/SelectableRelayListItem.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/SelectableRelayListItem.kt
new file mode 100644
index 0000000000..e66bfbd359
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/SelectableRelayListItem.kt
@@ -0,0 +1,206 @@
+package net.mullvad.mullvadvpn.lib.ui.component.relaylist
+
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+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.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.CornerSize
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material3.Icon
+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.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.times
+import net.mullvad.mullvadvpn.lib.resource.R
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible
+import net.mullvad.mullvadvpn.lib.theme.color.selected
+import net.mullvad.mullvadvpn.lib.ui.component.ExpandChevron
+import net.mullvad.mullvadvpn.lib.ui.designsystem.RelayListItem
+import net.mullvad.mullvadvpn.lib.ui.designsystem.RelayListItemDefaults
+import net.mullvad.mullvadvpn.lib.ui.designsystem.RelayListTokens
+import net.mullvad.mullvadvpn.lib.ui.tag.EXPAND_BUTTON_TEST_TAG
+
+@Composable
+@Preview
+private fun PreviewSelectableRelayLocationItem(
+ @PreviewParameter(SelectableRelayListItemPreviewParameterProvider::class)
+ relayItems: List<RelayListItem.SelectableItem>
+) {
+ AppTheme {
+ Column(Modifier.background(color = MaterialTheme.colorScheme.surface)) {
+ relayItems.map {
+ Spacer(Modifier.size(1.dp))
+ SelectableRelayListItem(relayListItem = it, onClick = {}, onToggleExpand = {})
+ }
+ }
+ }
+}
+
+@Composable
+fun SelectableRelayListItem(
+ relayListItem: RelayListItem.SelectableItem,
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit,
+ onLongClick: (() -> Unit)? = null,
+ onToggleExpand: ((Boolean) -> Unit),
+) {
+ RelayListItem(
+ modifier = modifier,
+ shape = relayListItem.itemPosition.toShape(),
+ selected = relayListItem.isSelected,
+ enabled = relayListItem.item.active,
+ content = {
+ Row(
+ modifier =
+ Modifier.fillMaxSize()
+ .padding(start = relayListItem.depth * Dimens.mediumPadding)
+ .padding(Dimens.mediumPadding),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(Dimens.smallPadding),
+ ) {
+ val iconTint =
+ when {
+ !relayListItem.item.active -> MaterialTheme.colorScheme.error
+ relayListItem.isSelected -> MaterialTheme.colorScheme.tertiary
+ else -> Color.Transparent
+ }
+ if (relayListItem.isSelected) {
+ Icon(
+ imageVector = Icons.Default.Check,
+ contentDescription = null,
+ tint = iconTint,
+ )
+ } else if (!relayListItem.item.active) {
+ InactiveRelayIndicator(iconTint)
+ }
+
+ Name(
+ name = relayListItem.item.name,
+ state = relayListItem.state,
+ active = relayListItem.item.active,
+ )
+ }
+ },
+ onClick = onClick,
+ onLongClick = onLongClick,
+ trailingContent =
+ if (relayListItem.item.hasChildren) {
+ {
+ ExpandChevron(
+ isExpanded = relayListItem.expanded,
+ modifier =
+ Modifier.clickable { onToggleExpand(!relayListItem.expanded) }
+ .fillMaxSize()
+ .padding(Dimens.mediumPadding)
+ .testTag(EXPAND_BUTTON_TEST_TAG),
+ )
+ }
+ } else {
+ null
+ },
+ colors =
+ RelayListItemDefaults.colors(containerColor = relayListItem.depth.toBackgroundColor()),
+ )
+}
+
+@Composable
+internal fun Name(
+ modifier: Modifier = Modifier,
+ name: String,
+ state: RelayListItemState?,
+ active: Boolean,
+) {
+ Text(
+ text = state?.let { name.withSuffix(state) } ?: name,
+ style = MaterialTheme.typography.bodyLarge,
+ modifier =
+ modifier.alpha(
+ if (state == null && active) {
+ AlphaVisible
+ } else {
+ RelayListTokens.RelayListItemDisabledLabelTextOpacity
+ }
+ ),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+}
+
+@Suppress("MagicNumber")
+@Composable
+internal fun Int.toBackgroundColor(): Color =
+ when (this) {
+ 0 -> MaterialTheme.colorScheme.surfaceContainerHighest
+ 1 -> MaterialTheme.colorScheme.surfaceContainerHigh
+ 2 -> MaterialTheme.colorScheme.surfaceContainerLow
+ else -> MaterialTheme.colorScheme.surfaceContainerLowest
+ }
+
+@Composable
+private fun String.withSuffix(state: RelayListItemState) =
+ when (state) {
+ RelayListItemState.USED_AS_EXIT -> stringResource(R.string.x_exit, this)
+ RelayListItemState.USED_AS_ENTRY -> stringResource(R.string.x_entry, this)
+ }
+
+@Composable
+fun InactiveRelayIndicator(tint: Color) {
+ Box(
+ modifier =
+ Modifier.size(Dimens.listIconSize)
+ .padding(Dimens.relayCirclePadding)
+ .background(color = tint, shape = CircleShape)
+ )
+}
+
+@Composable
+internal fun Modifier.clip(itemPosition: ItemPosition): Modifier {
+ val topCornerSize =
+ animateDpAsState(if (itemPosition.roundTop()) Dimens.relayItemCornerRadius else 0.dp)
+ val bottomCornerSize =
+ animateDpAsState(if (itemPosition.roundBottom()) Dimens.relayItemCornerRadius else 0.dp)
+ return clip(
+ RoundedCornerShape(
+ topStart = CornerSize(topCornerSize.value),
+ topEnd = CornerSize(topCornerSize.value),
+ bottomStart = CornerSize(bottomCornerSize.value),
+ bottomEnd = CornerSize(bottomCornerSize.value),
+ )
+ )
+}
+
+@Composable
+private fun ItemPosition.toShape(): Shape {
+ val topCornerSize = if (roundTop()) Dimens.relayItemCornerRadius else 0.dp
+ val bottomCornerSize = if (roundBottom()) Dimens.relayItemCornerRadius else 0.dp
+ return RoundedCornerShape(
+ topStart = CornerSize(topCornerSize),
+ topEnd = CornerSize(topCornerSize),
+ bottomStart = CornerSize(bottomCornerSize),
+ bottomEnd = CornerSize(bottomCornerSize),
+ )
+}
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/SelectableRelayListItemPreviewParameterProvider.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/SelectableRelayListItemPreviewParameterProvider.kt
new file mode 100644
index 0000000000..732c03bbc4
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/SelectableRelayListItemPreviewParameterProvider.kt
@@ -0,0 +1,66 @@
+package net.mullvad.mullvadvpn.lib.ui.component.relaylist
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+
+class SelectableRelayListItemPreviewParameterProvider :
+ PreviewParameterProvider<List<RelayListItem.SelectableItem>> {
+ override val values =
+ sequenceOf(
+ listOf(
+ RelayListItem.GeoLocationItem(
+ item =
+ generateRelayItemCountry(
+ name = "Relay country Active",
+ cityNames = listOf("Relay city 1", "Relay city 2"),
+ relaysPerCity = 2,
+ ),
+ isSelected = true,
+ expanded = false,
+ itemPosition = ItemPosition.Single,
+ ),
+ RelayListItem.GeoLocationItem(
+ item =
+ generateRelayItemCountry(
+ name = "Not Enabled Relay country",
+ cityNames = listOf("Not Enabled city"),
+ relaysPerCity = 1,
+ active = false,
+ ),
+ isSelected = false,
+ itemPosition = ItemPosition.Single,
+ ),
+ RelayListItem.GeoLocationItem(
+ item =
+ generateRelayItemCountry(
+ name = "Relay country Expanded",
+ cityNames = listOf("Normal city"),
+ relaysPerCity = 2,
+ ),
+ isSelected = true,
+ expanded = true,
+ itemPosition = ItemPosition.Single,
+ ),
+ RelayListItem.GeoLocationItem(
+ item =
+ generateRelayItemCountry(
+ name = "Country and city Expanded",
+ cityNames = listOf("Expanded city A", "Expanded city B"),
+ relaysPerCity = 2,
+ ),
+ isSelected = false,
+ itemPosition = ItemPosition.Single,
+ ),
+ RelayListItem.GeoLocationItem(
+ item =
+ generateRelayItemCountry(
+ name = "Country selected but inactive",
+ cityNames = listOf("Expanded city A", "Expanded city B"),
+ relaysPerCity = 2,
+ active = false,
+ ),
+ isSelected = true,
+ itemPosition = ItemPosition.Single,
+ ),
+ )
+ )
+}
diff --git a/android/lib/ui/designsystem/build.gradle.kts b/android/lib/ui/designsystem/build.gradle.kts
new file mode 100644
index 0000000000..efc9d0108b
--- /dev/null
+++ b/android/lib/ui/designsystem/build.gradle.kts
@@ -0,0 +1,43 @@
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.compose)
+}
+
+android {
+ namespace = "net.mullvad.mullvadvpn.lib.ui.designsystem"
+ compileSdk = libs.versions.compile.sdk.get().toInt()
+ buildToolsVersion = libs.versions.build.tools.get()
+
+ defaultConfig { minSdk = libs.versions.min.sdk.get().toInt() }
+
+ buildFeatures { compose = true }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = libs.versions.jvm.target.get()
+ allWarningsAsErrors = true
+ }
+
+ lint {
+ lintConfig = file("${rootProject.projectDir}/config/lint.xml")
+ abortOnError = true
+ warningsAsErrors = true
+ }
+}
+
+dependencies {
+ implementation(projects.lib.theme)
+ implementation(projects.lib.model)
+ implementation(projects.lib.ui.tag)
+
+ implementation(libs.compose.ui)
+ implementation(libs.compose.ui.tooling)
+ implementation(libs.compose.ui.tooling.preview)
+ implementation(libs.compose.material3)
+ implementation(libs.compose.icons.extended)
+}
diff --git a/android/lib/ui/designsystem/src/main/AndroidManifest.xml b/android/lib/ui/designsystem/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..b2d3ea1235
--- /dev/null
+++ b/android/lib/ui/designsystem/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
diff --git a/android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/Checkbox.kt b/android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/Checkbox.kt
new file mode 100644
index 0000000000..15e9556e47
--- /dev/null
+++ b/android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/Checkbox.kt
@@ -0,0 +1,56 @@
+package net.mullvad.mullvadvpn.lib.ui.designsystem
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material3.Checkbox as Material3Checkbox
+import androidx.compose.material3.CheckboxColors
+import androidx.compose.material3.CheckboxDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.lib.theme.color.selected
+
+@Composable
+fun Checkbox(
+ checked: Boolean,
+ onCheckedChange: ((Boolean) -> Unit)?,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ colors: CheckboxColors =
+ CheckboxDefaults.colors(
+ checkedColor = MaterialTheme.colorScheme.onPrimary,
+ uncheckedColor = MaterialTheme.colorScheme.onPrimary,
+ checkmarkColor = MaterialTheme.colorScheme.selected,
+ ),
+ interactionSource: MutableInteractionSource? = null,
+) {
+ Material3Checkbox(
+ checked = checked,
+ onCheckedChange = onCheckedChange,
+ modifier = modifier,
+ enabled = enabled,
+ colors = colors,
+ interactionSource = interactionSource,
+ )
+}
+
+@Preview
+@Composable
+private fun PreviewCheckbox() {
+ AppTheme {
+ Column(
+ Modifier.background(color = MaterialTheme.colorScheme.background),
+ verticalArrangement = Arrangement.spacedBy(Dimens.smallSpacer),
+ ) {
+ Checkbox(checked = false, null)
+ Checkbox(checked = true, null)
+ Checkbox(checked = false, null, enabled = false)
+ Checkbox(checked = true, null, enabled = false)
+ }
+ }
+}
diff --git a/android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/RelayListHeader.kt b/android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/RelayListHeader.kt
new file mode 100644
index 0000000000..3dc20ff0e7
--- /dev/null
+++ b/android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/RelayListHeader.kt
@@ -0,0 +1,78 @@
+package net.mullvad.mullvadvpn.lib.ui.designsystem
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+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.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+
+private val LIST_HEADER_MIN_HEIGHT = 48.dp
+
+@Composable
+fun RelayListHeader(
+ content: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ actions: @Composable (RowScope.() -> Unit)? = null,
+) {
+ ProvideContentColorTextStyle(
+ MaterialTheme.colorScheme.onBackground,
+ MaterialTheme.typography.bodyLarge,
+ ) {
+ Row(
+ modifier =
+ Modifier.padding(horizontal = Dimens.tinyPadding)
+ .defaultMinSize(minHeight = LIST_HEADER_MIN_HEIGHT)
+ .height(IntrinsicSize.Min)
+ .then(modifier),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ content()
+ HorizontalDivider(
+ Modifier.weight(1f, true).padding(start = Dimens.smallPadding),
+ color =
+ MaterialTheme.colorScheme.onBackground.copy(
+ alpha = RelayListHeaderTokens.RelayListHeaderDividerAlpha
+ ),
+ )
+ actions?.invoke(this)
+ }
+ }
+}
+
+object RelayListHeaderTokens {
+ const val RelayListHeaderDividerAlpha = 0.2f
+}
+
+@Preview(backgroundColor = 0xFF192E45, showBackground = true)
+@Composable
+fun PreviewRelayListHeader() {
+ AppTheme {
+ Column {
+ RelayListHeader(content = { Text("Header") })
+ RelayListHeader(
+ content = { Text("Header") },
+ actions = {
+ IconButton(onClick = {}) {
+ Icon(imageVector = Icons.Default.Edit, contentDescription = null)
+ }
+ },
+ )
+ }
+ }
+}
diff --git a/android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/RelayListItem.kt b/android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/RelayListItem.kt
new file mode 100644
index 0000000000..c2e9664a18
--- /dev/null
+++ b/android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/RelayListItem.kt
@@ -0,0 +1,313 @@
+package net.mullvad.mullvadvpn.lib.ui.designsystem
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.defaultMinSize
+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.width
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewFontScale
+import androidx.compose.ui.unit.dp
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.color.AlphaInactive
+
+@Composable
+fun RelayListItem(
+ modifier: Modifier = Modifier,
+ selected: Boolean = false,
+ enabled: Boolean = true,
+ onClick: (() -> Unit) = {},
+ onLongClick: (() -> Unit)? = {},
+ leadingContent: @Composable (() -> Unit)? = null,
+ content: @Composable () -> Unit,
+ trailingContent: @Composable (() -> Unit)? = null,
+ colors: RelayListItemColors = RelayListItemDefaults.colors(),
+ shape: Shape = RectangleShape,
+) {
+ Surface(
+ modifier =
+ modifier
+ .defaultMinSize(minHeight = RelayListTokens.listItemMinHeight)
+ .height(IntrinsicSize.Min),
+ shape = shape,
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(RelayListTokens.listItemSpacer),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (leadingContent != null) {
+ Box(
+ Modifier.background(colors.containerColor)
+ .width(RelayListTokens.listItemButtonWidth)
+ .fillMaxHeight(),
+ contentAlignment = Alignment.Center,
+ ) {
+ ProvideContentColorTextStyle(
+ colors.leadingIconColor,
+ MaterialTheme.typography.titleMedium,
+ ) {
+ leadingContent()
+ }
+ }
+ }
+
+ Row(
+ Modifier.weight(1f, fill = true)
+ .background(colors.containerColor)
+ .fillMaxHeight()
+ .combinedClickable(
+ enabled = true,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ ProvideContentColorTextStyle(
+ colors.headlineColor(enabled, selected),
+ MaterialTheme.typography.titleMedium,
+ ) {
+ content()
+ }
+ }
+
+ if (trailingContent != null) {
+ Box(
+ Modifier.background(color = colors.containerColor)
+ .width(RelayListTokens.listItemButtonWidth)
+ .fillMaxHeight()
+ ) {
+ ProvideContentColorTextStyle(
+ colors.trailingIconColor,
+ MaterialTheme.typography.titleMedium,
+ ) {
+ trailingContent()
+ }
+ }
+ }
+ }
+ }
+}
+
+// Based of ListItem
+@Immutable
+class RelayListItemColors(
+ val containerColor: Color,
+ val headlineColor: Color,
+ val leadingIconColor: Color,
+ val trailingIconColor: Color,
+ val selectedHeadlineColor: Color,
+ val disabledHeadlineColor: Color,
+) {
+ internal fun containerColor(): Color = containerColor
+
+ @Stable
+ internal fun headlineColor(enabled: Boolean, selected: Boolean): Color =
+ when {
+ !enabled -> disabledHeadlineColor
+ selected -> selectedHeadlineColor
+ else -> headlineColor
+ }
+}
+
+@Composable
+internal fun ProvideContentColorTextStyle(
+ contentColor: Color,
+ textStyle: TextStyle,
+ content: @Composable () -> Unit,
+) {
+ val mergedStyle = LocalTextStyle.current.merge(textStyle)
+ CompositionLocalProvider(
+ LocalContentColor provides contentColor,
+ LocalTextStyle provides mergedStyle,
+ content = content,
+ )
+}
+
+object RelayListItemDefaults {
+ @Composable
+ fun colors(
+ containerColor: Color = MaterialTheme.colorScheme.surface,
+ headlineColor: Color = MaterialTheme.colorScheme.onSurface,
+ leadingIconColor: Color = MaterialTheme.colorScheme.onSurface,
+ trailingIconColor: Color = MaterialTheme.colorScheme.onSurface,
+ selectedHeadlineColor: Color = MaterialTheme.colorScheme.tertiary,
+ disabledHeadlineColor: Color =
+ headlineColor.copy(alpha = RelayListTokens.RelayListItemDisabledLabelTextOpacity),
+ ): RelayListItemColors =
+ RelayListItemColors(
+ containerColor = containerColor,
+ headlineColor = headlineColor,
+ leadingIconColor = leadingIconColor,
+ trailingIconColor = trailingIconColor,
+ selectedHeadlineColor = selectedHeadlineColor,
+ disabledHeadlineColor = disabledHeadlineColor,
+ )
+}
+
+object RelayListTokens {
+ const val RelayListItemDisabledLabelTextOpacity = AlphaInactive
+
+ val listItemMinHeight = 56.dp
+ val listItemSpacer = 2.dp
+ val listItemButtonWidth = 56.dp
+}
+
+@Preview
+@PreviewFontScale
+@Composable
+private fun PreviewSimpleRelayListItem() {
+ AppTheme {
+ RelayListItem(
+ modifier = Modifier.fillMaxWidth(),
+ content = { Text("Hello world", modifier = Modifier.padding(16.dp).fillMaxSize()) },
+ )
+ }
+}
+
+@Preview
+@PreviewFontScale
+@Composable
+private fun PreviewLeadingRelayListItem() {
+ AppTheme {
+ RelayListItem(
+ modifier = Modifier.fillMaxWidth(),
+ content = {
+ Text(
+ "Hello world fsadhkuhfiuskahf iuhsadhuf sa",
+ modifier =
+ Modifier.padding(16.dp)
+ .fillMaxSize()
+ .wrapContentHeight(align = Alignment.CenterVertically),
+ style = MaterialTheme.typography.titleMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ },
+ leadingContent = {
+ Box(
+ modifier = Modifier.fillMaxSize().clickable(onClick = { /* Handle click */ }),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ modifier = Modifier.padding(16.dp),
+ imageVector = Icons.Default.KeyboardArrowDown,
+ contentDescription = null,
+ )
+ }
+ },
+ )
+ }
+}
+
+@Preview
+@PreviewFontScale
+@Composable
+private fun PreviewTrailingRelayListItem() {
+ AppTheme {
+ RelayListItem(
+ modifier = Modifier.fillMaxWidth(),
+ selected = true,
+ content = {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(imageVector = Icons.Default.Check, contentDescription = null)
+ Spacer(Modifier.width(8.dp))
+ Text(
+ "Hello world fsadhkuhfiuskahf iuhsadhuf sa",
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ },
+ trailingContent = {
+ Box(
+ modifier = Modifier.fillMaxSize().clickable(onClick = {}),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ modifier = Modifier.padding(16.dp),
+ imageVector = Icons.Default.Add,
+ contentDescription = null,
+ )
+ }
+ },
+ )
+ }
+}
+
+@Preview
+@PreviewFontScale
+@Composable
+private fun PreviewLeadingAndTrailingRelayListItem() {
+ AppTheme {
+ RelayListItem(
+ modifier = Modifier.fillMaxWidth(),
+ content = {
+ Text(
+ "Hello world iuhsadhuf sa",
+ modifier = Modifier.clickable {}.padding(16.dp).fillMaxSize(),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ },
+ leadingContent = {
+ Box(
+ modifier = Modifier.fillMaxSize().clickable(onClick = {}),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ modifier = Modifier.padding(16.dp),
+ imageVector = Icons.Default.KeyboardArrowDown,
+ contentDescription = null,
+ )
+ }
+ },
+ trailingContent = {
+ Box(
+ modifier = Modifier.fillMaxSize().clickable(onClick = {}),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ modifier = Modifier.padding(16.dp),
+ imageVector = Icons.Default.Add,
+ contentDescription = null,
+ )
+ }
+ },
+ )
+ }
+}