diff options
| author | David Göransson <david.goransson@mullvad.net> | 2025-07-04 16:12:32 +0200 |
|---|---|---|
| committer | David Göransson <david.goransson@mullvad.net> | 2025-07-04 16:12:32 +0200 |
| commit | 5300f1663559ebd7a87c699db8e858d13e6fa556 (patch) | |
| tree | 0081e14129def76d6a57b32232e42411c2fbe10d /android/lib | |
| parent | 3e5795cbab6866fcd3eafee58d1790fc9e7f1829 (diff) | |
| parent | 0d5660226494abaf04dc619997bf4d6a27c637d8 (diff) | |
| download | mullvadvpn-5300f1663559ebd7a87c699db8e858d13e6fa556.tar.xz mullvadvpn-5300f1663559ebd7a87c699db8e858d13e6fa556.zip | |
Merge branch 'implement-new-select-location-design-droid-1954'
Diffstat (limited to 'android/lib')
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, + ) + } + }, + ) + } +} |
