diff --git a/.github/workflows/auth-lint.yml b/.github/workflows/auth-lint.yml index 575a5e6dc3..2deaa285f7 100644 --- a/.github/workflows/auth-lint.yml +++ b/.github/workflows/auth-lint.yml @@ -30,6 +30,18 @@ jobs: exit 1 fi done + + - name: Verify all icons are less than 20KB + run: | + find assets/custom-icons -type f -name "*.svg" | while read -r file; do + if [[ "$file" == "assets/custom-icons/icons/bbs_nga.svg" ]]; then + continue + fi + if [[ "$(stat --printf="%s" "$file")" -gt 20480 ]]; then + echo "File size is greater than 20KB: $file ($file_size bytes)" + exit 1 + fi + done - name: Verify custom icon JSON run: cat assets/custom-icons/_data/custom-icons.json | jq empty diff --git a/README.md b/README.md index ad9c4970c0..9fd8fe8daa 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,10 @@ Learn more at [ente.io](https://ente.io).  -Our flagship product. 3x data replication. On device machine learning. Cross -platform. Private sharing. Collaborative albums. Family plans. Easy import, -easier export. Background uploads. The list goes on. And of course, all of this, -while being fully end-to-end encrypted. +Our flagship product. 3x data replication. Face detection. Semantic search. +Private sharing. Collaborative albums. Family plans. Easy import, easier export. +Background uploads. The list goes on. And of course, all of this, while being +fully end-to-end encrypted across platforms. Ente Photos is a paid service, but we offer 5GB of free storage. You can also clone this repository and choose to self-host. diff --git a/auth/assets/custom-icons/_data/custom-icons.json b/auth/assets/custom-icons/_data/custom-icons.json index be561e4a4c..f79149f99c 100644 --- a/auth/assets/custom-icons/_data/custom-icons.json +++ b/auth/assets/custom-icons/_data/custom-icons.json @@ -85,6 +85,17 @@ "币安" ] }, + { + "title": "Binance TR", + "slug": "binance_tr" + }, + { + "title": "BinanceUS", + "slug": "binance_us", + "altNames": [ + "Binance US" + ] + }, { "title": "Bitfinex" }, @@ -258,6 +269,10 @@ { "title": "Dropbox" }, + { + "title": "DreamHost Panel", + "slug": "dreamhost_panel" + }, { "title": "dus.net", "slug": "dusnet" @@ -433,6 +448,9 @@ { "title": "Kite" }, + { + "title": "Kotas" + }, { "title": "KnownHost", "altNames": [ @@ -515,7 +533,9 @@ }, { "title": "matlab", - "altNames": ["mathworks"] + "altNames": [ + "mathworks" + ] }, { "title": "Mercado Livre", @@ -527,7 +547,7 @@ ] }, { - "title": "Microsoft" + "title": "microsoft" }, { "title": "Microsoft 365", @@ -602,6 +622,9 @@ "title": "ngrok", "hex": "858585" }, + { + "title": "Nelnet" + }, { "title": "nintendo", "altNames": [ @@ -611,6 +634,14 @@ { "title": "Njalla" }, + { + "title": "nordvpn", + "slug": "nordaccount", + "hex": "#4687FF", + "altNames": [ + "Nord Account" + ] + }, { "title": "Notesnook" }, @@ -715,7 +746,8 @@ ] }, { - "title": "randstad" + "title": "randstad", + "hex": "#2175D9" }, { "title": "Real-Debrid", @@ -947,6 +979,10 @@ { "title": "Upstox" }, + { + "title": "US Mobile", + "slug": "us_mobile" + }, { "title": "Vikunja" }, @@ -957,7 +993,8 @@ ] }, { - "title": "WARGAMING.NET" + "title": "WARGAMING.NET", + "slug": "wargamingnet" }, { "title": "Wealthfront" @@ -1009,4 +1046,4 @@ ] } ] -} +} \ No newline at end of file diff --git a/auth/assets/custom-icons/icons/bbs_nga.svg b/auth/assets/custom-icons/icons/bbs_nga.svg index 4735844e74..d0e582e5b1 100644 --- a/auth/assets/custom-icons/icons/bbs_nga.svg +++ b/auth/assets/custom-icons/icons/bbs_nga.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/auth/assets/custom-icons/icons/bloom_host.svg b/auth/assets/custom-icons/icons/bloom_host.svg index 6f624a3265..9555afcb9e 100644 --- a/auth/assets/custom-icons/icons/bloom_host.svg +++ b/auth/assets/custom-icons/icons/bloom_host.svg @@ -1,7 +1 @@ - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/auth/assets/custom-icons/icons/bybit.svg b/auth/assets/custom-icons/icons/bybit.svg index 3413a18878..c792ecfc20 100644 --- a/auth/assets/custom-icons/icons/bybit.svg +++ b/auth/assets/custom-icons/icons/bybit.svg @@ -1 +1,38 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/auth/assets/custom-icons/icons/cloudns.svg b/auth/assets/custom-icons/icons/cloudns.svg index 9096df8c3c..cca37bd105 100644 --- a/auth/assets/custom-icons/icons/cloudns.svg +++ b/auth/assets/custom-icons/icons/cloudns.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/auth/assets/custom-icons/icons/digifinex.svg b/auth/assets/custom-icons/icons/digifinex.svg index 111b5ca533..3aa656462e 100644 --- a/auth/assets/custom-icons/icons/digifinex.svg +++ b/auth/assets/custom-icons/icons/digifinex.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/auth/assets/custom-icons/icons/dreamhost_panel.svg b/auth/assets/custom-icons/icons/dreamhost_panel.svg new file mode 100644 index 0000000000..ab64b80877 --- /dev/null +++ b/auth/assets/custom-icons/icons/dreamhost_panel.svg @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/auth/assets/custom-icons/icons/jianguoyun.svg b/auth/assets/custom-icons/icons/jianguoyun.svg index c2c9060389..dd662af6e5 100644 --- a/auth/assets/custom-icons/icons/jianguoyun.svg +++ b/auth/assets/custom-icons/icons/jianguoyun.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/auth/assets/custom-icons/icons/kotas.svg b/auth/assets/custom-icons/icons/kotas.svg new file mode 100644 index 0000000000..d81e5e99c1 --- /dev/null +++ b/auth/assets/custom-icons/icons/kotas.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/auth/assets/custom-icons/icons/mozilla.svg b/auth/assets/custom-icons/icons/mozilla.svg index ef061c6fb6..956cdfe2dc 100644 --- a/auth/assets/custom-icons/icons/mozilla.svg +++ b/auth/assets/custom-icons/icons/mozilla.svg @@ -1,112 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/auth/assets/custom-icons/icons/nelnet.svg b/auth/assets/custom-icons/icons/nelnet.svg new file mode 100644 index 0000000000..017fab82d5 --- /dev/null +++ b/auth/assets/custom-icons/icons/nelnet.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/auth/assets/custom-icons/icons/nordaccount.svg b/auth/assets/custom-icons/icons/nordaccount.svg new file mode 100644 index 0000000000..49f135c458 --- /dev/null +++ b/auth/assets/custom-icons/icons/nordaccount.svg @@ -0,0 +1 @@ +NordVPN \ No newline at end of file diff --git a/auth/assets/custom-icons/icons/randstad.svg b/auth/assets/custom-icons/icons/randstad.svg index 64d071e340..9f82ed9b03 100644 --- a/auth/assets/custom-icons/icons/randstad.svg +++ b/auth/assets/custom-icons/icons/randstad.svg @@ -1,19 +1,20 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/auth/assets/custom-icons/icons/tcpshield.svg b/auth/assets/custom-icons/icons/tcpshield.svg index 6e6914700f..101486f813 100644 --- a/auth/assets/custom-icons/icons/tcpshield.svg +++ b/auth/assets/custom-icons/icons/tcpshield.svg @@ -1,8 +1 @@ - - - - - - - - + \ No newline at end of file diff --git a/auth/assets/custom-icons/icons/us_mobile.svg b/auth/assets/custom-icons/icons/us_mobile.svg new file mode 100644 index 0000000000..b2535053a3 --- /dev/null +++ b/auth/assets/custom-icons/icons/us_mobile.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/auth/assets/custom-icons/icons/wargaming.svg b/auth/assets/custom-icons/icons/wargaming.svg deleted file mode 100644 index cceb66ccf6..0000000000 --- a/auth/assets/custom-icons/icons/wargaming.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/auth/assets/custom-icons/icons/wargamingnet.svg b/auth/assets/custom-icons/icons/wargamingnet.svg new file mode 100644 index 0000000000..4ed8043c12 --- /dev/null +++ b/auth/assets/custom-icons/icons/wargamingnet.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/auth/lib/l10n/arb/app_ar.arb b/auth/lib/l10n/arb/app_ar.arb index 7de520f13c..5af7df38bd 100644 --- a/auth/lib/l10n/arb/app_ar.arb +++ b/auth/lib/l10n/arb/app_ar.arb @@ -435,8 +435,6 @@ "customEndpoint": "متصل بـ{endpoint}", "pinText": "ثبت", "unpinText": "ألغِ التثبيت", - "pinnedCodeMessage": "ثُبِّت {code}", - "unpinnedCodeMessage": "أُلغِي تثبيت {code}", "tags": "الأوسمة", "createNewTag": "أنشيء وسم جديد", "tag": "وسم", diff --git a/auth/lib/l10n/arb/app_bg.arb b/auth/lib/l10n/arb/app_bg.arb index e0dd805583..b5b9ac0019 100644 --- a/auth/lib/l10n/arb/app_bg.arb +++ b/auth/lib/l10n/arb/app_bg.arb @@ -148,7 +148,7 @@ "hintForMobile": "Натиснете продължително код, за да го редактирате или премахнете.", "hintForDesktop": "Натиснете десен бутон върху код, за да го редактирате или премахнете.", "scan": "Сканиране", - "scanACode": "Скениране на код", + "scanACode": "Сканиране на код", "verify": "Потвърждаване", "verifyEmail": "Потвърдете имейла", "enterCodeHint": "Въведете 6-цифрения код от\nВашето приложение за удостоверяване", @@ -156,6 +156,7 @@ "twoFactorAuthTitle": "Двуфакторно удостоверяване", "passkeyAuthTitle": "Удостоверяване с ключ за парола", "verifyPasskey": "Потвърдете ключ за парола", + "loginWithTOTP": "Влизане с еднократен код", "recoverAccount": "Възстановяване на акаунт", "enterRecoveryKeyHint": "Въведете Вашия ключ за възстановяване", "recover": "Възстановяване", @@ -199,7 +200,7 @@ "sorryUnableToGenCode": "За съжаление не може да се генерира код за {issuerName}", "noResult": "Няма резултати", "addCode": "Добавяне на код", - "scanAQrCode": "Скениране на QR код", + "scanAQrCode": "Сканиране на QR код", "enterDetailsManually": "Въведете подробности ръчно", "edit": "Редактиране", "share": "Споделяне", @@ -327,6 +328,10 @@ } } }, + "manualSort": "Персонализирано", + "editOrder": "Промяна на подредбата", + "mostFrequentlyUsed": "Често използвани", + "mostRecentlyUsed": "Последно използвани", "activeSessions": "Активни сесии", "somethingWentWrongPleaseTryAgain": "Нещо се обърка, моля опитайте отново", "thisWillLogYouOutOfThisDevice": "Това ще Ви изкара от профила на това устройство!", @@ -444,10 +449,11 @@ "invalidEndpointMessage": "За съжаление въведената от Вас крайна точка е невалидна. Моля, въведете валидна крайна точка и опитайте отново.", "endpointUpdatedMessage": "Крайната точка е актуализирана успешно", "customEndpoint": "Свързан към {endpoint}", - "pinText": "ПИН код", + "pinText": "Закачане", "unpinText": "Откачане", "pinnedCodeMessage": "{code} е закачен", "unpinnedCodeMessage": "{code} е откачен", + "pinned": "Закачен", "tags": "Етикети", "createNewTag": "Създаване на етикет", "tag": "Етикет", diff --git a/auth/lib/l10n/arb/app_ca.arb b/auth/lib/l10n/arb/app_ca.arb index 649d0fdeaf..cf7d61349e 100644 --- a/auth/lib/l10n/arb/app_ca.arb +++ b/auth/lib/l10n/arb/app_ca.arb @@ -446,8 +446,6 @@ "customEndpoint": "Connectat a {endpoint}", "pinText": "Fixa", "unpinText": "Desfixa", - "pinnedCodeMessage": "{code} fixat", - "unpinnedCodeMessage": "{code} deixat de fixar", "tags": "Etiquetes", "createNewTag": "Crea una nova etiqueta", "tag": "Etiqueta", diff --git a/auth/lib/l10n/arb/app_da.arb b/auth/lib/l10n/arb/app_da.arb index f766cdbf40..770e70d89d 100644 --- a/auth/lib/l10n/arb/app_da.arb +++ b/auth/lib/l10n/arb/app_da.arb @@ -446,8 +446,6 @@ "customEndpoint": "Forbindelse oprettet til {endpoint}", "pinText": "Fastgør", "unpinText": "Frigør", - "pinnedCodeMessage": "{code} er blevet fastgjort", - "unpinnedCodeMessage": "{code} er blevet frigjort", "tags": "Tags", "createNewTag": "Opret nyt tag", "tag": "Tag", diff --git a/auth/lib/l10n/arb/app_de.arb b/auth/lib/l10n/arb/app_de.arb index d1bbcd5eb5..8bce3046c8 100644 --- a/auth/lib/l10n/arb/app_de.arb +++ b/auth/lib/l10n/arb/app_de.arb @@ -444,8 +444,6 @@ "customEndpoint": "Mit {endpoint} verbunden", "pinText": "Anpinnen", "unpinText": "Lösen", - "pinnedCodeMessage": "{code} wurde angepinnt", - "unpinnedCodeMessage": "{code} wurde Losgelöst", "tags": "Tags", "createNewTag": "Neuen Tag erstellen", "tag": "Tag", diff --git a/auth/lib/l10n/arb/app_el.arb b/auth/lib/l10n/arb/app_el.arb index 7a9b0aa5ff..8027a2fbc2 100644 --- a/auth/lib/l10n/arb/app_el.arb +++ b/auth/lib/l10n/arb/app_el.arb @@ -156,6 +156,7 @@ "twoFactorAuthTitle": "Αυθεντικοποίηση δύο παραγόντων", "passkeyAuthTitle": "Επιβεβαίωση κλειδιού πρόσβασης", "verifyPasskey": "Επιβεβαίωση κλειδιού πρόσβασης", + "loginWithTOTP": "Είσοδος με TOTP", "recoverAccount": "Ανάκτηση λογαριασμού", "enterRecoveryKeyHint": "Εισάγετε το κλειδί ανάκτησης σας", "recover": "Ανάκτηση", @@ -327,6 +328,10 @@ } } }, + "manualSort": "Προσαρμοσμένο", + "editOrder": "Επεξεργασία σειράς", + "mostFrequentlyUsed": "Συχνά χρησιμοποιούμενο", + "mostRecentlyUsed": "Πρόσφατα χρησιμοποιούμενο", "activeSessions": "Ενεργές συνεδρίες", "somethingWentWrongPleaseTryAgain": "Κάτι πήγε στραβά, παρακαλώ προσπαθήστε ξανά", "thisWillLogYouOutOfThisDevice": "Αυτό θα σας αποσυνδέσει από αυτή τη συσκευή!", @@ -446,8 +451,9 @@ "customEndpoint": "Συνδεδεμένο στο {endpoint}", "pinText": "Καρφίτσωμα", "unpinText": "Ξεκαρφίτσωμα", - "pinnedCodeMessage": "Το {code} καρφιτσώθηκε", - "unpinnedCodeMessage": "Το {code} ξεκαρφιτσώθηκε", + "pinnedCodeMessage": "{code} έχει καρφιτσωθεί", + "unpinnedCodeMessage": "Το {code} έχει ξεκαρφιτσωθεί", + "pinned": "Καρφιτσωμένο", "tags": "Ετικέτες", "createNewTag": "Δημιουργία Νέας Ετικέτας", "tag": "Ετικέτα", diff --git a/auth/lib/l10n/arb/app_en.arb b/auth/lib/l10n/arb/app_en.arb index b50a464108..c891ddebac 100644 --- a/auth/lib/l10n/arb/app_en.arb +++ b/auth/lib/l10n/arb/app_en.arb @@ -258,6 +258,10 @@ "areYouSureYouWantToLogout": "Are you sure you want to logout?", "yesLogout": "Yes, logout", "exit": "Exit", + "theme": "Theme", + "lightTheme": "Light", + "darkTheme": "Dark", + "systemTheme": "System", "verifyingRecoveryKey": "Verifying recovery key...", "recoveryKeyVerified": "Recovery key verified", "recoveryKeySuccessBody": "Great! Your recovery key is valid. Thank you for verifying.\n\nPlease remember to keep your recovery key safely backed up.", @@ -491,5 +495,12 @@ "appLockNotEnabled": "App lock not enabled", "appLockNotEnabledDescription": "Please enable app lock from Security > App Lock", "authToViewPasskey": "Please authenticate to view passkey", - "appLockOfflineModeWarning": "You have chosen to proceed without backups. If you forget your applock, you will be locked out from accessing your data." + "appLockOfflineModeWarning": "You have chosen to proceed without backups. If you forget your applock, you will be locked out from accessing your data.", + "duplicateCodes": "Duplicate codes", + "noDuplicates": "✨ No duplicates", + "youveNoDuplicateCodesThatCanBeCleared": "You've no duplicate codes that can be cleared", + "deduplicateCodes": "Deduplicate codes", + "deselectAll": "Deselect all", + "selectAll": "Select all", + "deleteDuplicates": "Delete duplicates" } \ No newline at end of file diff --git a/auth/lib/l10n/arb/app_es.arb b/auth/lib/l10n/arb/app_es.arb index 27453078f9..93a8c61069 100644 --- a/auth/lib/l10n/arb/app_es.arb +++ b/auth/lib/l10n/arb/app_es.arb @@ -328,6 +328,10 @@ } } }, + "manualSort": "Personalizado", + "editOrder": "Editar orden", + "mostFrequentlyUsed": "Usados frecuentemente", + "mostRecentlyUsed": "Usados recientemente", "activeSessions": "Sesiones activas", "somethingWentWrongPleaseTryAgain": "Algo ha ido mal, por favor, inténtelo de nuevo", "thisWillLogYouOutOfThisDevice": "¡Esto cerrará la sesión de este dispositivo!", @@ -449,6 +453,7 @@ "unpinText": "Desanclar", "pinnedCodeMessage": "{code} ha sido anclado", "unpinnedCodeMessage": "{code} ha sido desanclado", + "pinned": "Anclado", "tags": "Etiquetas", "createNewTag": "Crear Nueva Etiqueta", "tag": "Etiqueta", @@ -485,5 +490,12 @@ "appLockNotEnabled": "Bloqueo de aplicación no activado", "appLockNotEnabledDescription": "Por favor, activa el bloqueo de aplicación desde Seguridad > Bloqueo de aplicación", "authToViewPasskey": "Por favor, autentícate para ver tu clave de acceso", - "appLockOfflineModeWarning": "Has elegido proceder sin copia de seguridad. Si olvidas el código de desbloqueo de la aplicación, se bloqueará el acceso a sus datos." + "appLockOfflineModeWarning": "Has elegido proceder sin copia de seguridad. Si olvidas el código de desbloqueo de la aplicación, se bloqueará el acceso a sus datos.", + "duplicateCodes": "Duplicar códigos", + "noDuplicates": "✨ No hay duplicados", + "youveNoDuplicateCodesThatCanBeCleared": "No tienes códigos duplicados que se puedan borrar", + "deduplicateCodes": "Desduplicar códigos", + "deselectAll": "Deseleccionar todo", + "selectAll": "Seleccionar todo", + "deleteDuplicates": "Eliminar duplicados" } \ No newline at end of file diff --git a/auth/lib/l10n/arb/app_fa.arb b/auth/lib/l10n/arb/app_fa.arb index 98822602fc..e7691aa915 100644 --- a/auth/lib/l10n/arb/app_fa.arb +++ b/auth/lib/l10n/arb/app_fa.arb @@ -401,8 +401,6 @@ "customEndpoint": "متصل شده به {endpoint}", "pinText": "پین", "unpinText": "حذف پین", - "pinnedCodeMessage": "{code} پین شد", - "unpinnedCodeMessage": "{code} از پین حذف شد", "tags": "برچسبها", "createNewTag": "ایجاد برچسب جدید", "tag": "برچسب", diff --git a/auth/lib/l10n/arb/app_fr.arb b/auth/lib/l10n/arb/app_fr.arb index 9b6e02a8ee..6148d7e5df 100644 --- a/auth/lib/l10n/arb/app_fr.arb +++ b/auth/lib/l10n/arb/app_fr.arb @@ -445,8 +445,6 @@ "customEndpoint": "Connecté à {endpoint}", "pinText": "Épingler", "unpinText": "Désépingler", - "pinnedCodeMessage": "{code} a été épinglé", - "unpinnedCodeMessage": "{code} a été désépinglé", "tags": "Tags", "createNewTag": "Créer un nouveau tag", "tag": "Tag", diff --git a/auth/lib/l10n/arb/app_id.arb b/auth/lib/l10n/arb/app_id.arb index d7b53e0fd7..f6166796df 100644 --- a/auth/lib/l10n/arb/app_id.arb +++ b/auth/lib/l10n/arb/app_id.arb @@ -156,6 +156,7 @@ "twoFactorAuthTitle": "Autentikasi dua langkah", "passkeyAuthTitle": "Verifikasi passkey", "verifyPasskey": "Verifikasi passkey", + "loginWithTOTP": "Login menggunakan TOTP", "recoverAccount": "Pulihkan akun", "enterRecoveryKeyHint": "Masukkan kunci pemulihanmu", "recover": "Pulihkan", @@ -327,6 +328,10 @@ } } }, + "manualSort": "Kustom", + "editOrder": "Ubah pesanan", + "mostFrequentlyUsed": "Sering digunakan", + "mostRecentlyUsed": "Baru digunakan", "activeSessions": "Sesi aktif", "somethingWentWrongPleaseTryAgain": "Ada yang salah. Mohon coba kembali", "thisWillLogYouOutOfThisDevice": "Langkah ini akan mengeluarkan Anda dari gawai ini!", @@ -446,8 +451,6 @@ "customEndpoint": "Terkoneksi ke {endpoint}", "pinText": "Sematkan", "unpinText": "Awasematkan", - "pinnedCodeMessage": "{code} telah disematkan", - "unpinnedCodeMessage": "{code} telah diawasematkan", "tags": "Tanda", "createNewTag": "Buat Tanda Baru", "tag": "Tanda", diff --git a/auth/lib/l10n/arb/app_it.arb b/auth/lib/l10n/arb/app_it.arb index 4547e967f7..2c7b27235d 100644 --- a/auth/lib/l10n/arb/app_it.arb +++ b/auth/lib/l10n/arb/app_it.arb @@ -156,6 +156,7 @@ "twoFactorAuthTitle": "Autenticazione a due fattori", "passkeyAuthTitle": "Verifica della passkey", "verifyPasskey": "Verifica passkey", + "loginWithTOTP": "Login con TOTP", "recoverAccount": "Recupera account", "enterRecoveryKeyHint": "Inserisci la tua chiave di recupero", "recover": "Recupera", @@ -327,6 +328,10 @@ } } }, + "manualSort": "Personalizzato", + "editOrder": "Modifica ordine", + "mostFrequentlyUsed": "Utilizzato di frequente", + "mostRecentlyUsed": "Utilizzato di recente", "activeSessions": "Sessioni attive", "somethingWentWrongPleaseTryAgain": "Qualcosa è andato storto, per favore riprova", "thisWillLogYouOutOfThisDevice": "Questo ti disconnetterà da questo dispositivo!", @@ -448,6 +453,7 @@ "unpinText": "Sgancia", "pinnedCodeMessage": "{code} è stato fissato", "unpinnedCodeMessage": "{code} è stato sganciato", + "pinned": "Fissato", "tags": "Tag", "createNewTag": "Crea un nuovo tag", "tag": "Tag", @@ -484,5 +490,12 @@ "appLockNotEnabled": "Blocco app non abilitato", "appLockNotEnabledDescription": "Si prega di abilitare il blocco dell'app da Sicurezza > Blocco App", "authToViewPasskey": "Autenticati per visualizzare le tue passkey", - "appLockOfflineModeWarning": "Hai scelto di procedere senza backup. Se dimentichi il tuo codice di blocco dell'app, non potrai più accedere ai tuoi dati." + "appLockOfflineModeWarning": "Hai scelto di procedere senza backup. Se dimentichi il tuo codice di blocco dell'app, non potrai più accedere ai tuoi dati.", + "duplicateCodes": "Codici duplicati", + "noDuplicates": "✨ Nessun doppione", + "youveNoDuplicateCodesThatCanBeCleared": "Non ci sono codici duplicati che possono essere cancellati", + "deduplicateCodes": "Codici deduplicati", + "deselectAll": "Deselezionare tutti", + "selectAll": "Seleziona tutti", + "deleteDuplicates": "Elimina i duplicati" } \ No newline at end of file diff --git a/auth/lib/l10n/arb/app_ja.arb b/auth/lib/l10n/arb/app_ja.arb index 5b95f42313..8e90a8dbbf 100644 --- a/auth/lib/l10n/arb/app_ja.arb +++ b/auth/lib/l10n/arb/app_ja.arb @@ -156,6 +156,7 @@ "twoFactorAuthTitle": "2 要素認証", "passkeyAuthTitle": "パスキー認証", "verifyPasskey": "パスキーの認証", + "loginWithTOTP": "TOTPでログイン", "recoverAccount": "アカウントを回復", "enterRecoveryKeyHint": "回復キーを入力", "recover": "回復", @@ -327,6 +328,10 @@ } } }, + "manualSort": "カスタム", + "editOrder": "並べ替え", + "mostFrequentlyUsed": "よく使う", + "mostRecentlyUsed": "最近使った", "activeSessions": "アクティブセッション", "somethingWentWrongPleaseTryAgain": "問題が発生しました、再試行してください", "thisWillLogYouOutOfThisDevice": "このデバイスからログアウトします!", @@ -446,8 +451,9 @@ "customEndpoint": "{endpoint} に接続しました", "pinText": "固定", "unpinText": "固定を解除", - "pinnedCodeMessage": "{code} を固定しました", - "unpinnedCodeMessage": "{code} の固定が解除されました", + "pinnedCodeMessage": "{code}がピン留めされました", + "unpinnedCodeMessage": "{code}のピン留めが解除されました", + "pinned": "ピン留め", "tags": "タグ", "createNewTag": "新しいタグの作成", "tag": "タグ", diff --git a/auth/lib/l10n/arb/app_ko.arb b/auth/lib/l10n/arb/app_ko.arb index 936d4572f9..2322ce5962 100644 --- a/auth/lib/l10n/arb/app_ko.arb +++ b/auth/lib/l10n/arb/app_ko.arb @@ -328,6 +328,10 @@ } } }, + "manualSort": "사용자 정의", + "editOrder": "순서 변경", + "mostFrequentlyUsed": "자주 사용됨", + "mostRecentlyUsed": "최근에 사용됨", "activeSessions": "활성화된 세션", "somethingWentWrongPleaseTryAgain": "뭔가 잘못됐습니다, 다시 시도해주세요", "thisWillLogYouOutOfThisDevice": "이 작업을 하시면 기기에서 로그아웃하게 됩니다!", @@ -449,6 +453,7 @@ "unpinText": "핀 해제", "pinnedCodeMessage": "{code}가 핀 되었습니다.", "unpinnedCodeMessage": "{code}의 핀이 해제되었습니다.", + "pinned": "고정됨", "tags": "태그", "createNewTag": "새 태그 만들기", "tag": "태그", diff --git a/auth/lib/l10n/arb/app_lt.arb b/auth/lib/l10n/arb/app_lt.arb index bf9343443d..5e8c1143d3 100644 --- a/auth/lib/l10n/arb/app_lt.arb +++ b/auth/lib/l10n/arb/app_lt.arb @@ -328,6 +328,10 @@ } } }, + "manualSort": "Pasirinktinis", + "editOrder": "Redaguoti tvarką", + "mostFrequentlyUsed": "Dažniausiai naudojamą", + "mostRecentlyUsed": "Neseniai naudotą", "activeSessions": "Aktyvūs seansai", "somethingWentWrongPleaseTryAgain": "Kažkas nutiko ne taip. Bandykite dar kartą.", "thisWillLogYouOutOfThisDevice": "Tai jus atjungs nuo šio įrenginio.", @@ -449,6 +453,7 @@ "unpinText": "Atsegti", "pinnedCodeMessage": "{code} buvo prisegtas", "unpinnedCodeMessage": "{code} buvo atsegtas", + "pinned": "Prisegta", "tags": "Žymės", "createNewTag": "Kurti naują žymę", "tag": "Žymė", diff --git a/auth/lib/l10n/arb/app_ml.arb b/auth/lib/l10n/arb/app_ml.arb new file mode 100644 index 0000000000..9e26dfeeb6 --- /dev/null +++ b/auth/lib/l10n/arb/app_ml.arb @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/auth/lib/l10n/arb/app_nl.arb b/auth/lib/l10n/arb/app_nl.arb index d8859b4df9..a99b4f305b 100644 --- a/auth/lib/l10n/arb/app_nl.arb +++ b/auth/lib/l10n/arb/app_nl.arb @@ -156,6 +156,7 @@ "twoFactorAuthTitle": "Tweestapsverificatie", "passkeyAuthTitle": "Passkey verificatie", "verifyPasskey": "Bevestig passkey", + "loginWithTOTP": "Inloggen met TOTP", "recoverAccount": "Account herstellen", "enterRecoveryKeyHint": "Voer je herstelsleutel in", "recover": "Herstellen", @@ -327,6 +328,10 @@ } } }, + "manualSort": "Aangepast", + "editOrder": "Volgorde wijzigen", + "mostFrequentlyUsed": "Vaak gebruikt", + "mostRecentlyUsed": "Recent gebruikt", "activeSessions": "Actieve sessies", "somethingWentWrongPleaseTryAgain": "Er is iets fout gegaan, probeer het opnieuw", "thisWillLogYouOutOfThisDevice": "Dit zal je uitloggen van dit apparaat!", @@ -448,6 +453,7 @@ "unpinText": "Losmaken", "pinnedCodeMessage": "{code} is vastgezet", "unpinnedCodeMessage": "{code} is losgemaakt", + "pinned": "Vastgezet", "tags": "Labels", "createNewTag": "Nieuw label maken", "tag": "Label", diff --git a/auth/lib/l10n/arb/app_pl.arb b/auth/lib/l10n/arb/app_pl.arb index d96bdb7c00..baa5e5de48 100644 --- a/auth/lib/l10n/arb/app_pl.arb +++ b/auth/lib/l10n/arb/app_pl.arb @@ -328,6 +328,10 @@ } } }, + "manualSort": "Niestandardowe", + "editOrder": "Zmień kolejność", + "mostFrequentlyUsed": "Często używane", + "mostRecentlyUsed": "Ostatnio używane", "activeSessions": "Aktywne sesje", "somethingWentWrongPleaseTryAgain": "Coś poszło nie tak, spróbuj ponownie", "thisWillLogYouOutOfThisDevice": "To wyloguje Cię z tego urządzenia!", @@ -449,6 +453,7 @@ "unpinText": "Odepnij", "pinnedCodeMessage": "Przypięto {code}", "unpinnedCodeMessage": "Odpięto {code}", + "pinned": "Przypięte", "tags": "Etykiety", "createNewTag": "Utwórz nową etykietę", "tag": "Etykieta", @@ -485,5 +490,12 @@ "appLockNotEnabled": "Blokada aplikacji nie jest włączona", "appLockNotEnabledDescription": "Prosimy włączyć blokadę aplikacji z Zabezpieczenia > Blokada aplikacji", "authToViewPasskey": "Prosimy uwierzytelnić się, aby wyświetlić klucz dostępu", - "appLockOfflineModeWarning": "Wybrano kontynuowanie bez kopii zapasowych. Jeśli zapomnisz blokady aplikacji, utracisz dostęp do swoich danych." + "appLockOfflineModeWarning": "Wybrano kontynuowanie bez kopii zapasowych. Jeśli zapomnisz blokady aplikacji, utracisz dostęp do swoich danych.", + "duplicateCodes": "Duplikuj kody", + "noDuplicates": "✨ Brak duplikatów", + "youveNoDuplicateCodesThatCanBeCleared": "Nie masz duplikatów kodów, które mogą być wyczyszczone", + "deduplicateCodes": "Deduplikuj kody", + "deselectAll": "Odznacz wszystko", + "selectAll": "Zaznacz wszystko", + "deleteDuplicates": "Usuń duplikaty" } \ No newline at end of file diff --git a/auth/lib/l10n/arb/app_pt.arb b/auth/lib/l10n/arb/app_pt.arb index cb01abb4cf..79b7e69936 100644 --- a/auth/lib/l10n/arb/app_pt.arb +++ b/auth/lib/l10n/arb/app_pt.arb @@ -132,7 +132,7 @@ "general": "Geral", "settings": "Ajustes", "copied": "Copiado", - "pleaseTryAgain": "Tente de novo", + "pleaseTryAgain": "Tente novamente", "existingUser": "Usuário existente", "newUser": "Novo no Ente", "delete": "Excluir", @@ -142,7 +142,7 @@ "suggestFeatures": "Sugerir recursos", "faq": "Perguntas frequentes", "somethingWentWrongMessage": "Algo deu errado. Tente outra vez", - "leaveFamily": "Sair da família", + "leaveFamily": "Sair do plano familiar", "leaveFamilyMessage": "Deseja mesmo sair do plano familiar?", "inFamilyPlanMessage": "Você está em um plano familiar!", "hintForMobile": "Pressione em um código para editar ou excluir.", @@ -271,7 +271,7 @@ "recoveryKeyVerifyReason": "Sua chave de recuperação é a única maneira de recuperar suas fotos se você esqueceu sua senha. Você pode encontrar sua chave de recuperação em Opções > Conta.\n\nInsira sua chave de recuperação aqui para verificar se você a salvou corretamente.", "confirmYourRecoveryKey": "Confirme sua chave de recuperação", "confirm": "Confirmar", - "emailYourLogs": "Enviar logs por e-mail", + "emailYourLogs": "Enviar registros por e-mail", "pleaseSendTheLogsTo": "Envie os logs para \n{toEmail}", "copyEmailAddress": "Copiar endereço de e-mail", "exportLogs": "Exportar logs", @@ -328,6 +328,10 @@ } } }, + "manualSort": "Personalizado", + "editOrder": "Editar ordem", + "mostFrequentlyUsed": "Usado com frequência", + "mostRecentlyUsed": "Usado recentemente", "activeSessions": "Sessões ativas", "somethingWentWrongPleaseTryAgain": "Algo deu errado. Tente outra vez", "thisWillLogYouOutOfThisDevice": "Isso fará com que você saia deste dispositivo!", @@ -337,7 +341,7 @@ "thisDevice": "Esse dispositivo", "toResetVerifyEmail": "Para redefinir sua senha, verifique seu e-mail primeiramente.", "thisEmailIsAlreadyInUse": "Este e-mail já está em uso", - "verificationFailedPleaseTryAgain": "Falha na verificação. Tente novamente", + "verificationFailedPleaseTryAgain": "Falhou na verificação. Tente novamente", "yourVerificationCodeHasExpired": "Seu código de verificação expirou", "incorrectCode": "Código incorreto", "sorryTheCodeYouveEnteredIsIncorrect": "O código inserido está incorreto", @@ -354,7 +358,7 @@ "plainText": "Texto simples", "passwordToEncryptExport": "Senha para criptografar a exportação", "export": "Exportar", - "useOffline": "Usar sem backups", + "useOffline": "Usar sem cópia de segurança", "signInToBackup": "Entre para fazer backup de seus códigos", "singIn": "Entrar", "sigInBackupReminder": "Exporte seus códigos para garantir que você tenha uma cópia para restaurar.", @@ -449,6 +453,7 @@ "unpinText": "Desafixar", "pinnedCodeMessage": "{code} foi fixado", "unpinnedCodeMessage": "{code} foi desafixado", + "pinned": "Fixado", "tags": "Etiquetas", "createNewTag": "Criar nova etiqueta", "tag": "Etiqueta", @@ -485,5 +490,12 @@ "appLockNotEnabled": "Bloqueio de aplicativo não ativado", "appLockNotEnabledDescription": "Ative o bloqueio de aplicativo em Segurança > Bloqueio de aplicativo", "authToViewPasskey": "Autentique para ver a sua chave de acesso", - "appLockOfflineModeWarning": "Você prosseguiu sem cópias de segurança. Caso, se esqueça de seu aplicativo de bloqueio, você não poderá mais acessar seus dados." + "appLockOfflineModeWarning": "Você prosseguiu sem cópias de segurança. Caso, se esqueça de seu aplicativo de bloqueio, você não poderá mais acessar seus dados.", + "duplicateCodes": "Duplicar códigos", + "noDuplicates": "✨ Sem duplicados", + "youveNoDuplicateCodesThatCanBeCleared": "Você não possui códigos duplicados para limpar", + "deduplicateCodes": "Desduplicar códigos", + "deselectAll": "Deselecionar tudo", + "selectAll": "Selecionar tudo", + "deleteDuplicates": "Excluir duplicados" } \ No newline at end of file diff --git a/auth/lib/l10n/arb/app_ru.arb b/auth/lib/l10n/arb/app_ru.arb index 6c2e737db6..1fcb98334f 100644 --- a/auth/lib/l10n/arb/app_ru.arb +++ b/auth/lib/l10n/arb/app_ru.arb @@ -446,8 +446,6 @@ "customEndpoint": "Подключено к {endpoint}", "pinText": "Прикрепить", "unpinText": "Открепить", - "pinnedCodeMessage": "{code} прикреплен", - "unpinnedCodeMessage": "{code} откреплен", "tags": "Метки", "createNewTag": "Создать новую метку", "tag": "Метка", diff --git a/auth/lib/l10n/arb/app_sk.arb b/auth/lib/l10n/arb/app_sk.arb index 96756ab629..4b319f3f2a 100644 --- a/auth/lib/l10n/arb/app_sk.arb +++ b/auth/lib/l10n/arb/app_sk.arb @@ -45,7 +45,7 @@ "timeBasedKeyType": "Na základe času (TOTP)", "counterBasedKeyType": "Na základe počítadla (HOTP)", "saveAction": "Uložiť", - "nextTotpTitle": "ďalej", + "nextTotpTitle": "ďalší", "deleteCodeTitle": "Odstrániť položku?", "deleteCodeMessage": "Naozaj chcete odstrániť položku? Táto akcia je nezvratná.", "trashCode": "Odstrániť kód?", @@ -156,6 +156,7 @@ "twoFactorAuthTitle": "Dvojfaktorové overovanie", "passkeyAuthTitle": "Overenie pomocou passkey", "verifyPasskey": "Overiť passkey", + "loginWithTOTP": "Prihlásenie pomocou TOTP", "recoverAccount": "Obnoviť účet", "enterRecoveryKeyHint": "Vložte váš kód pre obnovenie", "recover": "Obnoviť", @@ -446,8 +447,6 @@ "customEndpoint": "Pripojený k endpointu {endpoint}", "pinText": "Pripnúť", "unpinText": "Odopnúť", - "pinnedCodeMessage": "{code} bol pripnutý", - "unpinnedCodeMessage": "{code} bol odopnutý", "tags": "Tagy", "createNewTag": "Vytvoriť nový tag", "tag": "Tag", diff --git a/auth/lib/l10n/arb/app_sl.arb b/auth/lib/l10n/arb/app_sl.arb index 6123ba22b0..0be8735800 100644 --- a/auth/lib/l10n/arb/app_sl.arb +++ b/auth/lib/l10n/arb/app_sl.arb @@ -327,6 +327,8 @@ } } }, + "mostFrequentlyUsed": "Pogosto uporabljeni", + "mostRecentlyUsed": "Nedavno uporabljeno", "activeSessions": "Aktivne seje", "somethingWentWrongPleaseTryAgain": "Nekaj je šlo narobe, prosimo poizkusite znova.", "thisWillLogYouOutOfThisDevice": "To vas bo odjavilo iz te naprave!", @@ -446,8 +448,7 @@ "customEndpoint": "Povezano na {endpoint}", "pinText": "Pripni", "unpinText": "Odpni", - "pinnedCodeMessage": "{code} je bila pripeta", - "unpinnedCodeMessage": "{code} je bila odpeta", + "pinned": "Pripeto", "tags": "Oznake", "createNewTag": "Ustvari novo oznako", "tag": "Oznaka", diff --git a/auth/lib/l10n/arb/app_tr.arb b/auth/lib/l10n/arb/app_tr.arb index ccb63e3994..c4d87cde5e 100644 --- a/auth/lib/l10n/arb/app_tr.arb +++ b/auth/lib/l10n/arb/app_tr.arb @@ -446,8 +446,6 @@ "customEndpoint": "Bağlandı: {endpoint}", "pinText": "Sabitle", "unpinText": "Sabitlemeyi kaldır", - "pinnedCodeMessage": "{code} sabitlendi", - "unpinnedCodeMessage": "{code} sabitlemesi kaldırıldı", "tags": "Etiketler", "createNewTag": "Yeni etiket oluştur", "tag": "Etiket", diff --git a/auth/lib/l10n/arb/app_uk.arb b/auth/lib/l10n/arb/app_uk.arb index 9b9b560da5..9706d8fe7d 100644 --- a/auth/lib/l10n/arb/app_uk.arb +++ b/auth/lib/l10n/arb/app_uk.arb @@ -115,14 +115,14 @@ "importCodeDelimiterInfo": "Коди можуть бути розділені комою або новим рядком", "selectFile": "Вибрати файл", "emailVerificationToggle": "Підтвердження адреси електронної пошти", - "emailVerificationEnableWarning": "Щоб уникнути блокування доступу до свого облікового запису, обов’язково збережіть копію двофакторної аутентифікації до своєї електронної пошти за межами Ente Auth, перш ніж увімкнути перевірку електронної пошти.", - "authToChangeEmailVerificationSetting": "Будь ласка, пройдіть аутентифікацію, щоб змінити перевірку адреси електронної пошти", + "emailVerificationEnableWarning": "Щоб уникнути блокування доступу до свого облікового запису, обов’язково збережіть копію двоетапної автентифікації до своєї електронної пошти за межами Ente Auth, перш ніж увімкнути перевірку електронної пошти.", + "authToChangeEmailVerificationSetting": "Будь ласка, пройдіть автентифікацію, щоб змінити перевірку адреси електронної пошти", "authenticateGeneric": "Будь ласка, авторизуйтеся", - "authToViewYourRecoveryKey": "Будь ласка, пройдіть аутентифікацію, щоб переглянути ваш ключ відновлення", - "authToChangeYourEmail": "Будь ласка, пройдіть аутентифікацію, щоб змінити адресу електронної пошти", - "authToChangeYourPassword": "Будь ласка, пройдіть аутентифікацію, щоб змінити ваш пароль", - "authToViewSecrets": "Будь ласка, пройдіть аутентифікацію, щоб переглянути ваші секретні коди", - "authToInitiateSignIn": "Будь ласка, пройдіть аутентифікацію, щоб розпочати вхід для резервного копіювання.", + "authToViewYourRecoveryKey": "Будь ласка, пройдіть автентифікацію, щоб переглянути ваш ключ відновлення", + "authToChangeYourEmail": "Будь ласка, пройдіть автентифікацію, щоб змінити адресу електронної пошти", + "authToChangeYourPassword": "Будь ласка, пройдіть автентифікацію, щоб змінити ваш пароль", + "authToViewSecrets": "Будь ласка, пройдіть автентифікацію, щоб переглянути ваші секретні коди", + "authToInitiateSignIn": "Будь ласка, пройдіть автентифікацію, щоб розпочати вхід для резервного копіювання.", "ok": "Ок", "cancel": "Скасувати", "yes": "Так", @@ -153,7 +153,7 @@ "verifyEmail": "Підтвердити електронну адресу", "enterCodeHint": "Введіть нижче шестизначний код із застосунку для автентифікації", "lostDeviceTitle": "Загубили пристрій?", - "twoFactorAuthTitle": "Двофакторна аутентифікація", + "twoFactorAuthTitle": "Двоетапна автентифікація", "passkeyAuthTitle": "Перевірка секретного ключа", "verifyPasskey": "Підтвердження секретного ключа", "loginWithTOTP": "Увійти за допомогою TOTP", @@ -194,7 +194,7 @@ "authToChangeLockscreenSetting": "Будь ласка, авторизуйтесь для зміни налаштувань екрану блокування", "deviceLockEnablePreSteps": "Для увімкнення блокування програми, будь ласка, налаштуйте пароль пристрою або блокування екрана в системних налаштуваннях.", "viewActiveSessions": "Показати активні сеанси", - "authToViewYourActiveSessions": "Будь ласка, пройдіть аутентифікацію, щоб переглянути ваші активні сеанси", + "authToViewYourActiveSessions": "Будь ласка, пройдіть автентифікацію, щоб переглянути ваші активні сеанси", "searchHint": "Пошук...", "search": "Пошук", "sorryUnableToGenCode": "Вибачте, не вдалося створити код для {issuerName}", @@ -328,6 +328,10 @@ } } }, + "manualSort": "Власні", + "editOrder": "Змінити порядок", + "mostFrequentlyUsed": "Часто використовувані", + "mostRecentlyUsed": "Нещодавно використані", "activeSessions": "Активні сеанси", "somethingWentWrongPleaseTryAgain": "Щось пішло не так, спробуйте, будь ласка, знову", "thisWillLogYouOutOfThisDevice": "Це призведе до виходу на цьому пристрої!", @@ -342,9 +346,9 @@ "incorrectCode": "Невірний код", "sorryTheCodeYouveEnteredIsIncorrect": "Вибачте, але введений вами код є невірним", "emailChangedTo": "Адресу електронної пошти змінено на {newEmail}", - "authenticationFailedPleaseTryAgain": "Аутентифікація не пройдена. Будь ласка, спробуйте ще раз", + "authenticationFailedPleaseTryAgain": "Автентифікація не пройдена. Будь ласка, спробуйте ще раз", "authenticationSuccessful": "Автентифікацію виконано!", - "twofactorAuthenticationSuccessfullyReset": "Двофакторна аутентифікація успішно скинута", + "twofactorAuthenticationSuccessfullyReset": "Двоетапна автентифікація успішно скинута", "incorrectRecoveryKey": "Неправильний ключ відновлення", "theRecoveryKeyYouEnteredIsIncorrect": "Ви ввели неправильний ключ відновлення", "enterPassword": "Введіть пароль", @@ -366,9 +370,9 @@ "focusOnSearchBar": "Сфокусуватися на пошуку після запуску програми", "confirmUpdatingkey": "Ви впевнені у тому, що бажаєте змінити секретний ключ?", "minimizeAppOnCopy": "Згорнути програму після копіювання", - "editCodeAuthMessage": "Аутентифікуйтесь, щоб змінити код", - "deleteCodeAuthMessage": "Аутентифікуйтесь, щоб видалити код", - "showQRAuthMessage": "Аутентифікуйтесь, щоб показати QR-код", + "editCodeAuthMessage": "Авторизуйтесь, щоб змінити код", + "deleteCodeAuthMessage": "Авторизуйтесь, щоб видалити код", + "showQRAuthMessage": "Авторизуйтесь, щоб показати QR-код", "confirmAccountDeleteTitle": "Підтвердіть видалення облікового запису", "confirmAccountDeleteMessage": "Цей обліковий запис є зв'язаним з іншими програмами Ente, якщо ви використовуєте якісь з них.\n\nВаші завантажені дані у всіх програмах Ente будуть заплановані до видалення, а обліковий запис буде видалено назавжди.", "androidBiometricHint": "Підтвердити ідентифікацію", @@ -387,11 +391,11 @@ "@androidCancelButton": { "description": "Message showed on a button that the user can click to leave the current dialog. It is used on Android side. Maximum 30 characters." }, - "androidSignInTitle": "Необхідна аутентифікація", + "androidSignInTitle": "Необхідна автентифікація", "@androidSignInTitle": { "description": "Message showed as a title in a dialog which indicates the user that they need to scan biometric to continue. It is used on Android side. Maximum 60 characters." }, - "androidBiometricRequiredTitle": "Потрібна біометрична аутентифікація", + "androidBiometricRequiredTitle": "Потрібна біометрична автентифікація", "@androidBiometricRequiredTitle": { "description": "Message showed as a title in a dialog which indicates the user has not set up biometric authentication on their device. It is used on Android side. Maximum 60 characters." }, @@ -407,7 +411,7 @@ "@goToSettings": { "description": "Message showed on a button that the user can click to go to settings pages from the current dialog. It is used on both Android and iOS side. Maximum 30 characters." }, - "androidGoToSettingsDescription": "Біометрична аутентифікація не налаштована на вашому пристрої. Перейдіть в 'Налаштування > Безпека', щоб додати біометричну аутентифікацію.", + "androidGoToSettingsDescription": "Біометрична автентифікація не налаштована на вашому пристрої. Перейдіть в «Налаштування > Безпека», щоб додати біометричну автентифікацію.", "@androidGoToSettingsDescription": { "description": "Message advising the user to go to the settings and configure biometric on their device. It shows in a dialog on Android side." }, @@ -415,7 +419,7 @@ "@iOSLockOut": { "description": "Message advising the user to re-enable biometrics on their device. It shows in a dialog on iOS side." }, - "iOSGoToSettingsDescription": "Біометрична аутентифікація не налаштована на вашому пристрої. Увімкніть TouchID або FaceID на вашому телефоні.", + "iOSGoToSettingsDescription": "Біометрична автентифікація не налаштована на вашому пристрої. Увімкніть TouchID або FaceID на вашому телефоні.", "@iOSGoToSettingsDescription": { "description": "Message advising the user to go to the settings and configure Biometrics for their device. It shows in a dialog on iOS side." }, @@ -449,6 +453,7 @@ "unpinText": "Відкріпити", "pinnedCodeMessage": "{code} закріплено", "unpinnedCodeMessage": "{code} відкріплено", + "pinned": "Закріплено", "tags": "Мітки", "createNewTag": "Створити нову мітку", "tag": "Мітка", diff --git a/auth/lib/l10n/arb/app_vi.arb b/auth/lib/l10n/arb/app_vi.arb index f62d6ade58..ed1131ac2c 100644 --- a/auth/lib/l10n/arb/app_vi.arb +++ b/auth/lib/l10n/arb/app_vi.arb @@ -328,6 +328,10 @@ } } }, + "manualSort": "Tùy chỉnh", + "editOrder": "Chỉnh sửa đơn hàng", + "mostFrequentlyUsed": "Thường dùng", + "mostRecentlyUsed": "Dùng gần đây", "activeSessions": "Các phiên làm việc hiện tại", "somethingWentWrongPleaseTryAgain": "Phát hiện có lỗi, xin thử lại", "thisWillLogYouOutOfThisDevice": "Thao tác này sẽ đăng xuất bạn khỏi thiết bị này!", @@ -449,6 +453,7 @@ "unpinText": "Bỏ ghim", "pinnedCodeMessage": "{code} đã được ghim", "unpinnedCodeMessage": "{code} đã được bỏ ghim", + "pinned": "Đã ghim", "tags": "Thẻ", "createNewTag": "Tạo thẻ mới", "tag": "Thẻ", diff --git a/auth/lib/l10n/arb/app_zh.arb b/auth/lib/l10n/arb/app_zh.arb index 83e83f3b48..55520cfe55 100644 --- a/auth/lib/l10n/arb/app_zh.arb +++ b/auth/lib/l10n/arb/app_zh.arb @@ -328,6 +328,10 @@ } } }, + "manualSort": "自定义", + "editOrder": "编辑顺序", + "mostFrequentlyUsed": "经常使用", + "mostRecentlyUsed": "最近使用", "activeSessions": "已登录的设备", "somethingWentWrongPleaseTryAgain": "出了点问题,请重试", "thisWillLogYouOutOfThisDevice": "这将使您登出该设备!", @@ -449,6 +453,7 @@ "unpinText": "取消置顶", "pinnedCodeMessage": "{code} 已被置顶", "unpinnedCodeMessage": "{code} 已被取消置顶", + "pinned": "已置顶", "tags": "标签", "createNewTag": "创建新标签", "tag": "标签", diff --git a/auth/lib/models/all_icon_data.dart b/auth/lib/models/all_icon_data.dart new file mode 100644 index 0000000000..732667a262 --- /dev/null +++ b/auth/lib/models/all_icon_data.dart @@ -0,0 +1,15 @@ +enum IconType { simpleIcon, customIcon } + +class AllIconData { + final String title; + final IconType type; + final String? color; + final String? slug; + + AllIconData({ + required this.title, + required this.type, + required this.color, + this.slug, + }); +} diff --git a/auth/lib/models/code_display.dart b/auth/lib/models/code_display.dart index 71b74c68f5..6b3d6bb1df 100644 --- a/auth/lib/models/code_display.dart +++ b/auth/lib/models/code_display.dart @@ -12,6 +12,8 @@ class CodeDisplay { String note; final List tags; int position; + String iconSrc; + String iconID; CodeDisplay({ this.pinned = false, @@ -21,8 +23,12 @@ class CodeDisplay { this.tags = const [], this.note = '', this.position = 0, + this.iconSrc = '', + this.iconID = '', }); + bool get isCustomIcon => (iconSrc != '' && iconID != ''); + // copyWith CodeDisplay copyWith({ bool? pinned, @@ -32,6 +38,8 @@ class CodeDisplay { List? tags, String? note, int? position, + String? iconSrc, + String? iconID, }) { final bool updatedPinned = pinned ?? this.pinned; final bool updatedTrashed = trashed ?? this.trashed; @@ -40,6 +48,8 @@ class CodeDisplay { final List updatedTags = tags ?? this.tags; final String updatedNote = note ?? this.note; final int updatedPosition = position ?? this.position; + final String updatedIconSrc = iconSrc ?? this.iconSrc; + final String updatedIconID = iconID ?? this.iconID; return CodeDisplay( pinned: updatedPinned, @@ -49,6 +59,8 @@ class CodeDisplay { tags: updatedTags, note: updatedNote, position: updatedPosition, + iconSrc: updatedIconSrc, + iconID: updatedIconID, ); } @@ -64,6 +76,8 @@ class CodeDisplay { tags: List.from(json['tags'] ?? []), note: json['note'] ?? '', position: json['position'] ?? 0, + iconSrc: json['iconSrc'] ?? 'ente', + iconID: json['iconID'] ?? '', ); } @@ -106,6 +120,8 @@ class CodeDisplay { 'tags': tags, 'note': note, 'position': position, + 'iconSrc': iconSrc, + 'iconID': iconID, }; } diff --git a/auth/lib/onboarding/view/setup_enter_secret_key_page.dart b/auth/lib/onboarding/view/setup_enter_secret_key_page.dart index 0491f48bdc..4857221638 100644 --- a/auth/lib/onboarding/view/setup_enter_secret_key_page.dart +++ b/auth/lib/onboarding/view/setup_enter_secret_key_page.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:ente_auth/core/event_bus.dart'; import 'package:ente_auth/events/codes_updated_event.dart'; import "package:ente_auth/l10n/l10n.dart"; +import 'package:ente_auth/models/all_icon_data.dart'; import 'package:ente_auth/models/code.dart'; import 'package:ente_auth/models/code_display.dart'; import 'package:ente_auth/onboarding/model/tag_enums.dart'; @@ -13,7 +14,10 @@ import 'package:ente_auth/onboarding/view/common/tag_chip.dart'; import 'package:ente_auth/store/code_display_store.dart'; import 'package:ente_auth/theme/ente_theme.dart'; import 'package:ente_auth/ui/components/buttons/button_widget.dart'; +import 'package:ente_auth/ui/components/custom_icon_widget.dart'; import 'package:ente_auth/ui/components/models/button_result.dart'; +import 'package:ente_auth/ui/custom_icon_page.dart'; +import 'package:ente_auth/ui/utils/icon_utils.dart'; import 'package:ente_auth/utils/dialog_util.dart'; import 'package:ente_auth/utils/toast_util.dart'; import 'package:ente_auth/utils/totp_util.dart'; @@ -42,6 +46,9 @@ class _SetupEnterSecretKeyPageState extends State { late List selectedTags = [...?widget.code?.display.tags]; List allTags = []; StreamSubscription? _streamSubscription; + bool isCustomIcon = false; + String _customIconID = ""; + late IconType _iconSrc; @override void initState() { @@ -81,6 +88,19 @@ class _SetupEnterSecretKeyPageState extends State { _limitTextLength(_accountController, _otherTextLimit); _limitTextLength(_secretController, _otherTextLimit); } + + isCustomIcon = widget.code?.display.isCustomIcon ?? false; + if (isCustomIcon) { + _customIconID = widget.code?.display.iconID ?? "ente"; + } else { + if (widget.code != null) { + _customIconID = widget.code!.issuer; + } + } + _iconSrc = widget.code?.display.iconSrc == "simpleIcon" + ? IconType.simpleIcon + : IconType.customIcon; + super.initState(); } @@ -280,9 +300,21 @@ class _SetupEnterSecretKeyPageState extends State { ), ], ), - const SizedBox( - height: 40, - ), + const SizedBox(height: 32), + if (widget.code != null) + CustomIconWidget(iconData: _customIconID), + const SizedBox(height: 24), + if (widget.code != null) + GestureDetector( + onTap: () async { + await navigateToCustomIconPage(); + }, + child: Text( + "Change Icon", + style: getEnteTextTheme(context).small, + ), + ), + const SizedBox(height: 40), SizedBox( width: 400, child: OutlinedButton( @@ -324,6 +356,11 @@ class _SetupEnterSecretKeyPageState extends State { widget.code?.display.copyWith(tags: selectedTags) ?? CodeDisplay(tags: selectedTags); display.note = notes; + + display.iconID = _customIconID.toLowerCase(); + display.iconSrc = + _iconSrc == IconType.simpleIcon ? 'simpleIcon' : 'customIcon'; + if (widget.code != null && widget.code!.secret != secret) { ButtonResult? result = await showChoiceActionSheet( context, @@ -373,4 +410,28 @@ class _SetupEnterSecretKeyPageState extends State { message ?? context.l10n.pleaseVerifyDetails, ); } + + Future navigateToCustomIconPage() async { + final allIcons = IconUtils.instance.getAllIcons(); + String currentIcon; + if (widget.code!.display.isCustomIcon) { + currentIcon = widget.code!.display.iconID; + } else { + currentIcon = widget.code!.issuer; + } + final AllIconData newCustomIcon = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) { + return CustomIconPage( + currentIcon: currentIcon, + allIcons: allIcons, + ); + }, + ), + ); + setState(() { + _customIconID = newCustomIcon.title; + _iconSrc = newCustomIcon.type; + }); + } } diff --git a/auth/lib/services/deduplication_service.dart b/auth/lib/services/deduplication_service.dart new file mode 100644 index 0000000000..5ca4b044d7 --- /dev/null +++ b/auth/lib/services/deduplication_service.dart @@ -0,0 +1,56 @@ +import 'package:ente_auth/models/code.dart'; +import 'package:ente_auth/store/code_store.dart'; +import 'package:logging/logging.dart'; + +class DeduplicationService { + final _logger = Logger("DeduplicationService"); + + DeduplicationService._privateConstructor(); + + static final DeduplicationService instance = + DeduplicationService._privateConstructor(); + + Future> getDuplicateCodes() async { + try { + final List result = await _getDuplicateCodes(); + return result; + } catch (e, s) { + _logger.severe("failed to get dedupeCode", e, s); + rethrow; + } + } + + Future> _getDuplicateCodes() async { + final codes = await CodeStore.instance.getAllCodes(); + final List duplicateCodes = []; + Map> uniqueCodes = {}; + + for (final code in codes) { + if (code.hasError || code.isTrashed) continue; + + final uniqueKey = "${code.secret}_${code.issuer}_${code.account}"; + + if (uniqueCodes.containsKey(uniqueKey)) { + uniqueCodes[uniqueKey]!.add(code); + } else { + uniqueCodes[uniqueKey] = [code]; + } + } + for (final key in uniqueCodes.keys) { + if (uniqueCodes[key]!.length > 1) { + duplicateCodes.add(DuplicateCodes(key, uniqueCodes[key]!)); + } + } + return duplicateCodes; + } +} + +class DuplicateCodes { + String hash; + final List codes; + + DuplicateCodes( + this.hash, + this.codes, + ); +} diff --git a/auth/lib/ui/code_widget.dart b/auth/lib/ui/code_widget.dart index 16c82c848b..c04feddfb0 100644 --- a/auth/lib/ui/code_widget.dart +++ b/auth/lib/ui/code_widget.dart @@ -445,13 +445,19 @@ class _CodeWidgetState extends State { } Widget _getIcon() { + final String iconData; + if (widget.code.display.isCustomIcon) { + iconData = widget.code.display.iconID; + } else { + iconData = widget.code.issuer; + } return Padding( padding: _shouldShowLargeIcon ? EdgeInsets.only(left: widget.isCompactMode ? 12 : 16) : const EdgeInsets.all(0), child: IconUtils.instance.getIcon( context, - safeDecode(widget.code.issuer).trim(), + safeDecode(iconData).trim(), width: widget.isCompactMode ? (_shouldShowLargeIcon ? 32 : 24) : (_shouldShowLargeIcon ? 42 : 24), diff --git a/auth/lib/ui/components/custom_icon_widget.dart b/auth/lib/ui/components/custom_icon_widget.dart new file mode 100644 index 0000000000..4825ddff60 --- /dev/null +++ b/auth/lib/ui/components/custom_icon_widget.dart @@ -0,0 +1,37 @@ +import 'package:ente_auth/theme/ente_theme.dart'; +import 'package:ente_auth/ui/utils/icon_utils.dart'; +import 'package:ente_auth/utils/totp_util.dart'; +import 'package:flutter/material.dart'; + +class CustomIconWidget extends StatelessWidget { + final String iconData; + + CustomIconWidget({ + super.key, + required this.iconData, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: 70, + height: 70, + decoration: BoxDecoration( + border: Border.all( + width: 1.5, + color: getEnteColorScheme(context).tagChipSelectedColor, + ), + borderRadius: const BorderRadius.all(Radius.circular(12.0)), + ), + padding: const EdgeInsets.all(8), + child: FittedBox( + fit: BoxFit.contain, + child: IconUtils.instance.getIcon( + context, + safeDecode(iconData).trim(), + width: 50, + ), + ), + ); + } +} diff --git a/auth/lib/ui/custom_icon_page.dart b/auth/lib/ui/custom_icon_page.dart new file mode 100644 index 0000000000..01edbea9be --- /dev/null +++ b/auth/lib/ui/custom_icon_page.dart @@ -0,0 +1,216 @@ +import 'package:ente_auth/l10n/l10n.dart'; +import 'package:ente_auth/models/all_icon_data.dart'; +import 'package:ente_auth/services/preference_service.dart'; +import 'package:ente_auth/theme/ente_theme.dart'; +import 'package:ente_auth/ui/utils/icon_utils.dart'; +import 'package:flutter/material.dart'; + +class CustomIconPage extends StatefulWidget { + final Map allIcons; + final String currentIcon; + + const CustomIconPage({ + super.key, + required this.allIcons, + required this.currentIcon, + }); + + @override + State createState() => _CustomIconPageState(); +} + +class _CustomIconPageState extends State { + Map _filteredIcons = {}; + bool _showSearchBox = false; + final bool _autoFocusSearch = + PreferenceService.instance.shouldAutoFocusOnSearchBar(); + final TextEditingController _textController = TextEditingController(); + String _searchText = ""; + + // Used to request focus on the search box when clicked the search icon + late FocusNode searchBoxFocusNode; + + @override + void initState() { + _filteredIcons = widget.allIcons; + _showSearchBox = _autoFocusSearch; + searchBoxFocusNode = FocusNode(); + super.initState(); + } + + @override + void dispose() { + _textController.dispose(); + searchBoxFocusNode.dispose(); + super.dispose(); + } + + void _applyFilteringAndRefresh() { + if (_searchText.isEmpty) { + setState(() { + _filteredIcons = widget.allIcons; + }); + return; + } + + final filteredIcons = {}; + widget.allIcons.forEach((title, iconData) { + if (title.toLowerCase().contains(_searchText.toLowerCase())) { + filteredIcons[title] = iconData; + } + }); + + setState(() { + _filteredIcons = filteredIcons; + }); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Scaffold( + appBar: AppBar( + title: !_showSearchBox + ? const Text('Custom Branding') + : TextField( + autocorrect: false, + enableSuggestions: false, + autofocus: _autoFocusSearch, + controller: _textController, + onChanged: (value) { + _searchText = value; + _applyFilteringAndRefresh(); + }, + decoration: InputDecoration( + hintText: l10n.searchHint, + border: InputBorder.none, + focusedBorder: InputBorder.none, + ), + focusNode: searchBoxFocusNode, + ), + actions: [ + IconButton( + icon: _showSearchBox + ? const Icon(Icons.clear) + : const Icon(Icons.search), + tooltip: "Search", + onPressed: () { + setState( + () { + _showSearchBox = !_showSearchBox; + if (!_showSearchBox) { + _textController.clear(); + _searchText = ""; + } else { + _searchText = _textController.text; + + // Request focus on the search box + searchBoxFocusNode.requestFocus(); + } + _applyFilteringAndRefresh(); + }, + ); + }, + ), + ], + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + Expanded( + child: GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: (MediaQuery.sizeOf(context).width ~/ 90) + .clamp(1, double.infinity) + .toInt(), + crossAxisSpacing: 14, + mainAxisSpacing: 14, + childAspectRatio: 1, + ), + itemCount: _filteredIcons.length, + itemBuilder: (context, index) { + final title = _filteredIcons.keys.elementAt(index); + final iconData = _filteredIcons[title]!; + IconType iconType = iconData.type; + String? color = iconData.color; + String? slug = iconData.slug; + + Widget iconWidget; + if (iconType == IconType.simpleIcon) { + iconWidget = IconUtils.instance.getSVGIcon( + "assets/simple-icons/icons/$title.svg", + title, + color, + 40, + context, + ); + } else { + iconWidget = IconUtils.instance.getSVGIcon( + "assets/custom-icons/icons/${slug ?? title}.svg", + title, + color, + 40, + context, + ); + } + + return GestureDetector( + onTap: () { + final newIcon = AllIconData( + title: title, + type: iconType, + color: color, + slug: slug, + ); + Navigator.of(context).pop(newIcon); + }, + child: Container( + decoration: BoxDecoration( + border: Border.all( + width: 1.5, + color: title.toLowerCase() == + widget.currentIcon.toLowerCase() + ? getEnteColorScheme(context) + .tagChipSelectedColor + : Colors.transparent, + ), + borderRadius: const BorderRadius.all( + Radius.circular(12.0), + ), + ), + child: Column( + children: [ + const SizedBox(height: 8), + Expanded( + child: iconWidget, + ), + const SizedBox(height: 12), + Padding( + padding: title.toLowerCase() == + widget.currentIcon.toLowerCase() + ? const EdgeInsets.only(left: 2, right: 2) + : const EdgeInsets.all(0.0), + child: Text( + '${title[0].toUpperCase()}${title.substring(1)}', + style: getEnteTextTheme(context).mini, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + const SizedBox(height: 4), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/auth/lib/ui/home_page.dart b/auth/lib/ui/home_page.dart index e18cfa45ae..1a2d0a83a7 100644 --- a/auth/lib/ui/home_page.dart +++ b/auth/lib/ui/home_page.dart @@ -217,10 +217,11 @@ class _HomePageState extends State { void sortFilteredCodes(List codes, CodeSortKey sortKey) { switch (sortKey) { case CodeSortKey.issuerName: - codes.sort((a, b) => a.issuer.compareTo(b.issuer)); + codes.sort((a, b) => compareAsciiLowerCaseNatural(a.issuer, b.issuer)); break; case CodeSortKey.accountName: - codes.sort((a, b) => a.account.compareTo(b.account)); + codes + .sort((a, b) => compareAsciiLowerCaseNatural(a.account, b.account)); break; case CodeSortKey.mostFrequentlyUsed: codes.sort((a, b) => b.display.tapCount.compareTo(a.display.tapCount)); diff --git a/auth/lib/ui/reorder_codes_page.dart b/auth/lib/ui/reorder_codes_page.dart index 7d79949b0f..4dd693e8c3 100644 --- a/auth/lib/ui/reorder_codes_page.dart +++ b/auth/lib/ui/reorder_codes_page.dart @@ -3,6 +3,7 @@ import 'package:ente_auth/l10n/l10n.dart'; import 'package:ente_auth/models/code.dart'; import 'package:ente_auth/services/preference_service.dart'; import 'package:ente_auth/store/code_store.dart'; +import 'package:ente_auth/theme/ente_theme.dart'; import 'package:ente_auth/ui/code_widget.dart'; import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; @@ -16,71 +17,71 @@ class ReorderCodesPage extends StatefulWidget { } class _ReorderCodesPageState extends State { - int selectedSortOption = 2; + bool hasChanged = false; final logger = Logger('ReorderCodesPage'); @override Widget build(BuildContext context) { final bool isCompactMode = PreferenceService.instance.isCompactMode(); - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, result) async { - if (!didPop) { - final hasSaved = await saveUpadedIndexes(); - if (hasSaved) { + return Scaffold( + appBar: AppBar( + title: const Text("Custom order"), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () async { Navigator.of(context).pop(); - } - } - }, - child: Scaffold( - appBar: AppBar( - title: Text(context.l10n.editOrder), - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () async { + }, + ), + actions: [ + GestureDetector( + onTap: () async { final hasSaved = await saveUpadedIndexes(); if (hasSaved) { Navigator.of(context).pop(); } }, + child: Padding( + padding: const EdgeInsets.only(right: 20), + child: Text( + context.l10n.save, + style: TextStyle( + color: hasChanged + ? getEnteColorScheme(context).textBase + : getEnteColorScheme(context).strokeMuted, + ), + ), + ), ), - ), - body: ReorderableListView( - buildDefaultDragHandles: false, - proxyDecorator: - (Widget child, int index, Animation animation) { - return AnimatedBuilder( - animation: animation, - builder: (BuildContext context, _) { - final animValue = Curves.easeInOut.transform(animation.value); - final scale = lerpDouble(1, 1.05, animValue)!; - return Transform.scale(scale: scale, child: child); - }, - ); - }, - children: [ - for (final code in widget.codes) - selectedSortOption == 2 - ? ReorderableDragStartListener( - key: ValueKey('${code.hashCode}_${code.generatedID}'), - index: widget.codes.indexOf(code), - child: CodeWidget( - key: ValueKey(code.generatedID), - code, - isCompactMode: isCompactMode, - ), - ) - : CodeWidget( - key: ValueKey('${code.hashCode}_${code.generatedID}'), - code, - isCompactMode: isCompactMode, - ), - ], - onReorder: (oldIndex, newIndex) { - if (selectedSortOption == 2) updateCodeIndex(oldIndex, newIndex); - }, - ), + ], + ), + body: ReorderableListView( + buildDefaultDragHandles: false, + proxyDecorator: (Widget child, int index, Animation animation) { + return AnimatedBuilder( + animation: animation, + builder: (BuildContext context, _) { + final animValue = Curves.easeInOut.transform(animation.value); + final scale = lerpDouble(1, 1.05, animValue)!; + return Transform.scale(scale: scale, child: child); + }, + ); + }, + children: [ + for (final code in widget.codes) + ReorderableDragStartListener( + key: ValueKey('${code.hashCode}_${code.generatedID}'), + index: widget.codes.indexOf(code), + child: CodeWidget( + key: ValueKey(code.generatedID), + code, + isCompactMode: isCompactMode, + ), + ), + ], + onReorder: (oldIndex, newIndex) { + updateCodeIndex(oldIndex, newIndex); + }, ), ); } @@ -97,6 +98,7 @@ class _ReorderCodesPageState extends State { if (oldIndex < newIndex) newIndex -= 1; final Code code = widget.codes.removeAt(oldIndex); widget.codes.insert(newIndex, code); + hasChanged = true; }); } } diff --git a/auth/lib/ui/settings/data/data_section_widget.dart b/auth/lib/ui/settings/data/data_section_widget.dart index f32739d239..5665d58532 100644 --- a/auth/lib/ui/settings/data/data_section_widget.dart +++ b/auth/lib/ui/settings/data/data_section_widget.dart @@ -1,11 +1,16 @@ +import 'dart:async'; + import 'package:ente_auth/l10n/l10n.dart'; +import 'package:ente_auth/services/deduplication_service.dart'; import 'package:ente_auth/theme/ente_theme.dart'; import 'package:ente_auth/ui/components/captioned_text_widget.dart'; import 'package:ente_auth/ui/components/expandable_menu_item_widget.dart'; import 'package:ente_auth/ui/components/menu_item_widget.dart'; import 'package:ente_auth/ui/settings/common_settings.dart'; +import 'package:ente_auth/ui/settings/data/duplicate_code_page.dart'; import 'package:ente_auth/ui/settings/data/export_widget.dart'; import 'package:ente_auth/ui/settings/data/import_page.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; import 'package:ente_auth/utils/navigation_util.dart'; import 'package:flutter/material.dart'; @@ -53,6 +58,33 @@ class DataSectionWidget extends StatelessWidget { }, ), sectionOptionSpacing, + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: l10n.duplicateCodes, + ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () async { + final List duplicateCodes = + await DeduplicationService.instance.getDuplicateCodes(); + if (duplicateCodes.isEmpty) { + unawaited( + showErrorDialog( + context, + l10n.noDuplicates, + l10n.youveNoDuplicateCodesThatCanBeCleared, + ), + ); + return; + } + await routeToPage( + context, + DuplicateCodePage(duplicateCodes: duplicateCodes), + ); + }, + ), + sectionOptionSpacing, ]); return Column( children: children, diff --git a/auth/lib/ui/settings/data/duplicate_code_page.dart b/auth/lib/ui/settings/data/duplicate_code_page.dart new file mode 100644 index 0000000000..9de8695a52 --- /dev/null +++ b/auth/lib/ui/settings/data/duplicate_code_page.dart @@ -0,0 +1,259 @@ +import 'package:ente_auth/l10n/l10n.dart'; +import 'package:ente_auth/models/code.dart'; +import 'package:ente_auth/services/deduplication_service.dart'; +import 'package:ente_auth/services/local_authentication_service.dart'; +import 'package:ente_auth/store/code_store.dart'; +import 'package:ente_auth/theme/ente_theme.dart'; +import 'package:ente_auth/ui/code_widget.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import 'package:logging/logging.dart'; + +class DuplicateCodePage extends StatefulWidget { + final List duplicateCodes; + const DuplicateCodePage({ + super.key, + required this.duplicateCodes, + }); + + @override + State createState() => _DuplicateCodePageState(); +} + +class _DuplicateCodePageState extends State { + final Logger _logger = Logger("DuplicateCodePage"); + late List _duplicateCodes; + final Set selectedGrids = {}; + + @override + void initState() { + _duplicateCodes = widget.duplicateCodes; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(context.l10n.deduplicateCodes), + elevation: 0, + ), + body: _getBody(), + ); + } + + Widget _getBody() { + final l10n = context.l10n; + return SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + GestureDetector( + onTap: () { + if (selectedGrids.length == _duplicateCodes.length) { + _removeAllGrids(); + } else { + _selectAllGrids(); + } + setState(() {}); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + selectedGrids.length == _duplicateCodes.length + ? l10n.deselectAll + : l10n.selectAll, + style: + Theme.of(context).textTheme.titleMedium!.copyWith( + fontSize: 14, + color: Theme.of(context) + .iconTheme + .color! + .withOpacity(0.7), + ), + ), + const Padding(padding: EdgeInsets.only(left: 4)), + selectedGrids.length == _duplicateCodes.length + ? const Icon( + Icons.check_circle, + size: 24, + ) + : Icon( + Icons.check_circle_outlined, + color: getEnteColorScheme(context).strokeMuted, + size: 24, + ), + ], + ), + ), + ], + ), + ), + ), + const SizedBox(height: 8), + Expanded( + child: ListView.builder( + itemCount: _duplicateCodes.length, + shrinkWrap: true, + itemBuilder: (context, index) { + final List codes = _duplicateCodes[index].codes; + return _getGridView( + codes, + index, + ); + }, + ), + ), + selectedGrids.isEmpty ? const SizedBox.shrink() : _getDeleteButton(), + ], + ), + ); + } + + Widget _getGridView(List code, int itemIndex) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + child: GestureDetector( + onTap: () { + if (selectedGrids.contains(itemIndex)) { + selectedGrids.remove(itemIndex); + } else { + selectedGrids.add(itemIndex); + } + setState(() {}); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "${code[0].issuer}, ${code.length} items", + ), + !selectedGrids.contains(itemIndex) + ? Icon( + Icons.check_circle_outlined, + color: getEnteColorScheme(context).strokeMuted, + size: 24, + ) + : const Icon( + Icons.check_circle, + size: 24, + ), + ], + ), + ), + ), + AlignedGridView.count( + crossAxisCount: (MediaQuery.sizeOf(context).width ~/ 400) + .clamp(1, double.infinity) + .toInt(), + padding: const EdgeInsets.only(bottom: 40), + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + return CodeWidget( + key: ValueKey('${code.hashCode}_$index'), + code[index], + isCompactMode: false, + ); + }, + itemCount: code.length, + ), + ], + ); + } + + Widget _getDeleteButton() { + int selectedItemsCount = 0; + for (int idx = 0; idx < _duplicateCodes.length; idx++) { + if (selectedGrids.contains(idx)) { + selectedItemsCount += _duplicateCodes[idx].codes.length - 1; + } + } + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 20), + child: SizedBox( + width: 400, + child: OutlinedButton( + onPressed: () async { + await deleteDuplicates(selectedItemsCount); + }, + child: Text( + "Delete $selectedItemsCount items", + ), + ), + ), + ); + } + + void _selectAllGrids() { + selectedGrids.clear(); + for (int idx = 0; idx < _duplicateCodes.length; idx++) { + selectedGrids.add(idx); + } + } + + void _removeAllGrids() { + selectedGrids.clear(); + } + + Future deleteDuplicates(int itemCount) async { + bool isAuthSuccessful = + await LocalAuthenticationService.instance.requestLocalAuthentication( + context, + context.l10n.deleteCodeAuthMessage, + ); + if (!isAuthSuccessful) { + return; + } + FocusScope.of(context).requestFocus(); + final l10n = context.l10n; + final String message = "Are you sure you want to trash $itemCount items?"; + await showChoiceActionSheet( + context, + title: l10n.deleteDuplicates, + body: message, + firstButtonLabel: l10n.trash, + isCritical: true, + firstButtonOnTap: () async { + try { + for (int idx = 0; idx < _duplicateCodes.length; idx++) { + if (selectedGrids.contains(idx)) { + final List codes = _duplicateCodes[idx].codes; + for (int i = 1; i < codes.length; i++) { + final display = codes[i].display; + final Code code = codes[i].copyWith( + display: display.copyWith(trashed: true), + ); + await CodeStore.instance.addCode(code); + } + } + } + Navigator.of(context).pop(); + } catch (e) { + _logger.severe('Failed to trash duplicate codes: ${e.toString()}'); + showGenericErrorDialog(context: context, error: e).ignore(); + } + }, + ); + } +} diff --git a/auth/lib/ui/settings/data/export_widget.dart b/auth/lib/ui/settings/data/export_widget.dart index 0df7482898..6d32424d3f 100644 --- a/auth/lib/ui/settings/data/export_widget.dart +++ b/auth/lib/ui/settings/data/export_widget.dart @@ -1,6 +1,5 @@ import 'dart:convert'; import 'dart:io'; - import 'package:ente_auth/core/configuration.dart'; import 'package:ente_auth/l10n/l10n.dart'; import 'package:ente_auth/models/export/ente.dart'; @@ -9,16 +8,15 @@ import 'package:ente_auth/store/code_store.dart'; import 'package:ente_auth/ui/components/buttons/button_widget.dart'; import 'package:ente_auth/ui/components/dialog_widget.dart'; import 'package:ente_auth/ui/components/models/button_type.dart'; +import 'package:ente_auth/ui/settings/data/html_export.dart'; import 'package:ente_auth/utils/dialog_util.dart'; import 'package:ente_auth/utils/platform_util.dart'; import 'package:ente_auth/utils/share_utils.dart'; import 'package:ente_auth/utils/toast_util.dart'; import 'package:ente_crypto_dart/ente_crypto_dart.dart'; import 'package:file_saver/file_saver.dart'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; -import 'package:logging/logging.dart'; import 'package:share_plus/share_plus.dart'; Future handleExportClick(BuildContext context) async { @@ -41,13 +39,22 @@ Future handleExportClick(BuildContext context) async { isInAlert: true, buttonAction: ButtonAction.second, ), + const ButtonWidget( + buttonType: ButtonType.secondary, + labelText: "HTML", + buttonSize: ButtonSize.large, + isInAlert: true, + buttonAction: ButtonAction.third, + ), ], ); if (result?.action != null && result!.action != ButtonAction.cancel) { if (result.action == ButtonAction.first) { await _requestForEncryptionPassword(context); - } else { - await _showExportWarningDialog(context); + } else if (result.action == ButtonAction.second) { + await _showExportWarningDialog(context, "txt"); + } else if (result.action == ButtonAction.third) { + await _showExportWarningDialog(context, "html"); } } } @@ -98,9 +105,8 @@ Future _requestForEncryptionPassword( ), ); // get json value of data - await _exportCodes(context, jsonEncode(data.toJson())); - } catch (e, s) { - Logger("ExportWidget").severe(e, s); + await _exportCodes(context, jsonEncode(data.toJson()), "txt"); + } catch (e) { showToast(context, "Error while exporting codes."); } } @@ -108,26 +114,34 @@ Future _requestForEncryptionPassword( ); } -Future _showExportWarningDialog(BuildContext context) async { +Future _showExportWarningDialog(BuildContext context, String type) async { await showChoiceActionSheet( context, title: context.l10n.warning, body: context.l10n.exportWarningDesc, isCritical: true, firstButtonOnTap: () async { - final data = await _getAuthDataForExport(); - await _exportCodes(context, data); + if (type == "html") { + final data = await generateHtml(context); + await _exportCodes(context, data, type); + } else { + final data = await _getAuthDataForExport(); + await _exportCodes(context, data, type); + } }, secondButtonLabel: context.l10n.cancel, firstButtonLabel: context.l10n.iUnderStand, ); } -Future _exportCodes(BuildContext context, String fileContent) async { +Future _exportCodes( + BuildContext context, + String fileContent, + String extension, +) async { DateTime now = DateTime.now().toUtc(); String formattedDate = DateFormat('yyyy-MM-dd').format(now); String exportFileName = 'ente-auth-codes-$formattedDate'; - String exportFileExtension = 'txt'; final hasAuthenticated = await LocalAuthenticationService.instance .requestLocalAuthentication(context, context.l10n.authToExportCodes); await PlatformUtil.refocusWindows(); @@ -142,14 +156,14 @@ Future _exportCodes(BuildContext context, String fileContent) async { saveAction: () async { await PlatformUtil.shareFile( exportFileName, - exportFileExtension, + extension, CryptoUtil.strToBin(fileContent), MimeType.text, ); }, sendAction: () async { final codeFile = File( - "${Configuration.instance.getTempDirectory()}$exportFileName.$exportFileExtension", + "${Configuration.instance.getTempDirectory()}$exportFileName.$extension", ); if (codeFile.existsSync()) { await codeFile.delete(); diff --git a/auth/lib/ui/settings/data/html_export.dart b/auth/lib/ui/settings/data/html_export.dart new file mode 100644 index 0000000000..705bd8507c --- /dev/null +++ b/auth/lib/ui/settings/data/html_export.dart @@ -0,0 +1,236 @@ +import 'dart:convert'; +import 'dart:ui' as ui; + +import 'package:ente_auth/models/code.dart'; +import 'package:ente_auth/store/code_store.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +Future generateQRImageBase64(String data) async { + final qrPainter = QrPainter( + data: data, + version: QrVersions.auto, + eyeStyle: const QrEyeStyle( + eyeShape: QrEyeShape.square, + color: Colors.black, + ), + dataModuleStyle: const QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.square, + color: Colors.black, + ), + ); + + const size = 250.0; + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + qrPainter.paint(canvas, const Size(size, size)); + final picture = recorder.endRecording(); + final img = await picture.toImage(size.toInt(), size.toInt()); + final byteData = await img.toByteData(format: ui.ImageByteFormat.png); + final pngBytes = byteData!.buffer.asUint8List(); + + return base64Encode(pngBytes); +} + +Future generateOTPEntryHtml( + Code code, + BuildContext context, +) async { + final qrBase64 = await generateQRImageBase64(code.rawData); + String notes = code.display.note; + if (notes.isNotEmpty) { + notes = 'Note: $notes'; + } + return ''' + + + ${code.issuer} + ${code.account} + + Type: ${code.type.name} + Algorithm: ${code.algorithm.name} + Digits: ${code.digits} + Recovery Code: ${code.secret} + $notes + + + + + + + '''; +} + +Future generateHtml(BuildContext context) async { + DateTime now = DateTime.now().toUtc(); + String formattedDate = DateFormat('d MMMM, yyyy').format(now); + final allCodes = await CodeStore.instance.getAllCodes(); + final List enteries = []; + + for (final code in allCodes) { + if (code.hasError) continue; + final entry = await generateOTPEntryHtml(code, context); + enteries.add(entry); + } + + return ''' + + + + + + + + Ente Auth + OTP Data Export + $formattedDate + + + + + ${enteries.join('')} + + + + + + + + +
codes; + + DuplicateCodes( + this.hash, + this.codes, + ); +} diff --git a/auth/lib/ui/code_widget.dart b/auth/lib/ui/code_widget.dart index 16c82c848b..c04feddfb0 100644 --- a/auth/lib/ui/code_widget.dart +++ b/auth/lib/ui/code_widget.dart @@ -445,13 +445,19 @@ class _CodeWidgetState extends State { } Widget _getIcon() { + final String iconData; + if (widget.code.display.isCustomIcon) { + iconData = widget.code.display.iconID; + } else { + iconData = widget.code.issuer; + } return Padding( padding: _shouldShowLargeIcon ? EdgeInsets.only(left: widget.isCompactMode ? 12 : 16) : const EdgeInsets.all(0), child: IconUtils.instance.getIcon( context, - safeDecode(widget.code.issuer).trim(), + safeDecode(iconData).trim(), width: widget.isCompactMode ? (_shouldShowLargeIcon ? 32 : 24) : (_shouldShowLargeIcon ? 42 : 24), diff --git a/auth/lib/ui/components/custom_icon_widget.dart b/auth/lib/ui/components/custom_icon_widget.dart new file mode 100644 index 0000000000..4825ddff60 --- /dev/null +++ b/auth/lib/ui/components/custom_icon_widget.dart @@ -0,0 +1,37 @@ +import 'package:ente_auth/theme/ente_theme.dart'; +import 'package:ente_auth/ui/utils/icon_utils.dart'; +import 'package:ente_auth/utils/totp_util.dart'; +import 'package:flutter/material.dart'; + +class CustomIconWidget extends StatelessWidget { + final String iconData; + + CustomIconWidget({ + super.key, + required this.iconData, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: 70, + height: 70, + decoration: BoxDecoration( + border: Border.all( + width: 1.5, + color: getEnteColorScheme(context).tagChipSelectedColor, + ), + borderRadius: const BorderRadius.all(Radius.circular(12.0)), + ), + padding: const EdgeInsets.all(8), + child: FittedBox( + fit: BoxFit.contain, + child: IconUtils.instance.getIcon( + context, + safeDecode(iconData).trim(), + width: 50, + ), + ), + ); + } +} diff --git a/auth/lib/ui/custom_icon_page.dart b/auth/lib/ui/custom_icon_page.dart new file mode 100644 index 0000000000..01edbea9be --- /dev/null +++ b/auth/lib/ui/custom_icon_page.dart @@ -0,0 +1,216 @@ +import 'package:ente_auth/l10n/l10n.dart'; +import 'package:ente_auth/models/all_icon_data.dart'; +import 'package:ente_auth/services/preference_service.dart'; +import 'package:ente_auth/theme/ente_theme.dart'; +import 'package:ente_auth/ui/utils/icon_utils.dart'; +import 'package:flutter/material.dart'; + +class CustomIconPage extends StatefulWidget { + final Map allIcons; + final String currentIcon; + + const CustomIconPage({ + super.key, + required this.allIcons, + required this.currentIcon, + }); + + @override + State createState() => _CustomIconPageState(); +} + +class _CustomIconPageState extends State { + Map _filteredIcons = {}; + bool _showSearchBox = false; + final bool _autoFocusSearch = + PreferenceService.instance.shouldAutoFocusOnSearchBar(); + final TextEditingController _textController = TextEditingController(); + String _searchText = ""; + + // Used to request focus on the search box when clicked the search icon + late FocusNode searchBoxFocusNode; + + @override + void initState() { + _filteredIcons = widget.allIcons; + _showSearchBox = _autoFocusSearch; + searchBoxFocusNode = FocusNode(); + super.initState(); + } + + @override + void dispose() { + _textController.dispose(); + searchBoxFocusNode.dispose(); + super.dispose(); + } + + void _applyFilteringAndRefresh() { + if (_searchText.isEmpty) { + setState(() { + _filteredIcons = widget.allIcons; + }); + return; + } + + final filteredIcons = {}; + widget.allIcons.forEach((title, iconData) { + if (title.toLowerCase().contains(_searchText.toLowerCase())) { + filteredIcons[title] = iconData; + } + }); + + setState(() { + _filteredIcons = filteredIcons; + }); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Scaffold( + appBar: AppBar( + title: !_showSearchBox + ? const Text('Custom Branding') + : TextField( + autocorrect: false, + enableSuggestions: false, + autofocus: _autoFocusSearch, + controller: _textController, + onChanged: (value) { + _searchText = value; + _applyFilteringAndRefresh(); + }, + decoration: InputDecoration( + hintText: l10n.searchHint, + border: InputBorder.none, + focusedBorder: InputBorder.none, + ), + focusNode: searchBoxFocusNode, + ), + actions: [ + IconButton( + icon: _showSearchBox + ? const Icon(Icons.clear) + : const Icon(Icons.search), + tooltip: "Search", + onPressed: () { + setState( + () { + _showSearchBox = !_showSearchBox; + if (!_showSearchBox) { + _textController.clear(); + _searchText = ""; + } else { + _searchText = _textController.text; + + // Request focus on the search box + searchBoxFocusNode.requestFocus(); + } + _applyFilteringAndRefresh(); + }, + ); + }, + ), + ], + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + Expanded( + child: GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: (MediaQuery.sizeOf(context).width ~/ 90) + .clamp(1, double.infinity) + .toInt(), + crossAxisSpacing: 14, + mainAxisSpacing: 14, + childAspectRatio: 1, + ), + itemCount: _filteredIcons.length, + itemBuilder: (context, index) { + final title = _filteredIcons.keys.elementAt(index); + final iconData = _filteredIcons[title]!; + IconType iconType = iconData.type; + String? color = iconData.color; + String? slug = iconData.slug; + + Widget iconWidget; + if (iconType == IconType.simpleIcon) { + iconWidget = IconUtils.instance.getSVGIcon( + "assets/simple-icons/icons/$title.svg", + title, + color, + 40, + context, + ); + } else { + iconWidget = IconUtils.instance.getSVGIcon( + "assets/custom-icons/icons/${slug ?? title}.svg", + title, + color, + 40, + context, + ); + } + + return GestureDetector( + onTap: () { + final newIcon = AllIconData( + title: title, + type: iconType, + color: color, + slug: slug, + ); + Navigator.of(context).pop(newIcon); + }, + child: Container( + decoration: BoxDecoration( + border: Border.all( + width: 1.5, + color: title.toLowerCase() == + widget.currentIcon.toLowerCase() + ? getEnteColorScheme(context) + .tagChipSelectedColor + : Colors.transparent, + ), + borderRadius: const BorderRadius.all( + Radius.circular(12.0), + ), + ), + child: Column( + children: [ + const SizedBox(height: 8), + Expanded( + child: iconWidget, + ), + const SizedBox(height: 12), + Padding( + padding: title.toLowerCase() == + widget.currentIcon.toLowerCase() + ? const EdgeInsets.only(left: 2, right: 2) + : const EdgeInsets.all(0.0), + child: Text( + '${title[0].toUpperCase()}${title.substring(1)}', + style: getEnteTextTheme(context).mini, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + const SizedBox(height: 4), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/auth/lib/ui/home_page.dart b/auth/lib/ui/home_page.dart index e18cfa45ae..1a2d0a83a7 100644 --- a/auth/lib/ui/home_page.dart +++ b/auth/lib/ui/home_page.dart @@ -217,10 +217,11 @@ class _HomePageState extends State { void sortFilteredCodes(List codes, CodeSortKey sortKey) { switch (sortKey) { case CodeSortKey.issuerName: - codes.sort((a, b) => a.issuer.compareTo(b.issuer)); + codes.sort((a, b) => compareAsciiLowerCaseNatural(a.issuer, b.issuer)); break; case CodeSortKey.accountName: - codes.sort((a, b) => a.account.compareTo(b.account)); + codes + .sort((a, b) => compareAsciiLowerCaseNatural(a.account, b.account)); break; case CodeSortKey.mostFrequentlyUsed: codes.sort((a, b) => b.display.tapCount.compareTo(a.display.tapCount)); diff --git a/auth/lib/ui/reorder_codes_page.dart b/auth/lib/ui/reorder_codes_page.dart index 7d79949b0f..4dd693e8c3 100644 --- a/auth/lib/ui/reorder_codes_page.dart +++ b/auth/lib/ui/reorder_codes_page.dart @@ -3,6 +3,7 @@ import 'package:ente_auth/l10n/l10n.dart'; import 'package:ente_auth/models/code.dart'; import 'package:ente_auth/services/preference_service.dart'; import 'package:ente_auth/store/code_store.dart'; +import 'package:ente_auth/theme/ente_theme.dart'; import 'package:ente_auth/ui/code_widget.dart'; import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; @@ -16,71 +17,71 @@ class ReorderCodesPage extends StatefulWidget { } class _ReorderCodesPageState extends State { - int selectedSortOption = 2; + bool hasChanged = false; final logger = Logger('ReorderCodesPage'); @override Widget build(BuildContext context) { final bool isCompactMode = PreferenceService.instance.isCompactMode(); - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, result) async { - if (!didPop) { - final hasSaved = await saveUpadedIndexes(); - if (hasSaved) { + return Scaffold( + appBar: AppBar( + title: const Text("Custom order"), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () async { Navigator.of(context).pop(); - } - } - }, - child: Scaffold( - appBar: AppBar( - title: Text(context.l10n.editOrder), - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () async { + }, + ), + actions: [ + GestureDetector( + onTap: () async { final hasSaved = await saveUpadedIndexes(); if (hasSaved) { Navigator.of(context).pop(); } }, + child: Padding( + padding: const EdgeInsets.only(right: 20), + child: Text( + context.l10n.save, + style: TextStyle( + color: hasChanged + ? getEnteColorScheme(context).textBase + : getEnteColorScheme(context).strokeMuted, + ), + ), + ), ), - ), - body: ReorderableListView( - buildDefaultDragHandles: false, - proxyDecorator: - (Widget child, int index, Animation animation) { - return AnimatedBuilder( - animation: animation, - builder: (BuildContext context, _) { - final animValue = Curves.easeInOut.transform(animation.value); - final scale = lerpDouble(1, 1.05, animValue)!; - return Transform.scale(scale: scale, child: child); - }, - ); - }, - children: [ - for (final code in widget.codes) - selectedSortOption == 2 - ? ReorderableDragStartListener( - key: ValueKey('${code.hashCode}_${code.generatedID}'), - index: widget.codes.indexOf(code), - child: CodeWidget( - key: ValueKey(code.generatedID), - code, - isCompactMode: isCompactMode, - ), - ) - : CodeWidget( - key: ValueKey('${code.hashCode}_${code.generatedID}'), - code, - isCompactMode: isCompactMode, - ), - ], - onReorder: (oldIndex, newIndex) { - if (selectedSortOption == 2) updateCodeIndex(oldIndex, newIndex); - }, - ), + ], + ), + body: ReorderableListView( + buildDefaultDragHandles: false, + proxyDecorator: (Widget child, int index, Animation animation) { + return AnimatedBuilder( + animation: animation, + builder: (BuildContext context, _) { + final animValue = Curves.easeInOut.transform(animation.value); + final scale = lerpDouble(1, 1.05, animValue)!; + return Transform.scale(scale: scale, child: child); + }, + ); + }, + children: [ + for (final code in widget.codes) + ReorderableDragStartListener( + key: ValueKey('${code.hashCode}_${code.generatedID}'), + index: widget.codes.indexOf(code), + child: CodeWidget( + key: ValueKey(code.generatedID), + code, + isCompactMode: isCompactMode, + ), + ), + ], + onReorder: (oldIndex, newIndex) { + updateCodeIndex(oldIndex, newIndex); + }, ), ); } @@ -97,6 +98,7 @@ class _ReorderCodesPageState extends State { if (oldIndex < newIndex) newIndex -= 1; final Code code = widget.codes.removeAt(oldIndex); widget.codes.insert(newIndex, code); + hasChanged = true; }); } } diff --git a/auth/lib/ui/settings/data/data_section_widget.dart b/auth/lib/ui/settings/data/data_section_widget.dart index f32739d239..5665d58532 100644 --- a/auth/lib/ui/settings/data/data_section_widget.dart +++ b/auth/lib/ui/settings/data/data_section_widget.dart @@ -1,11 +1,16 @@ +import 'dart:async'; + import 'package:ente_auth/l10n/l10n.dart'; +import 'package:ente_auth/services/deduplication_service.dart'; import 'package:ente_auth/theme/ente_theme.dart'; import 'package:ente_auth/ui/components/captioned_text_widget.dart'; import 'package:ente_auth/ui/components/expandable_menu_item_widget.dart'; import 'package:ente_auth/ui/components/menu_item_widget.dart'; import 'package:ente_auth/ui/settings/common_settings.dart'; +import 'package:ente_auth/ui/settings/data/duplicate_code_page.dart'; import 'package:ente_auth/ui/settings/data/export_widget.dart'; import 'package:ente_auth/ui/settings/data/import_page.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; import 'package:ente_auth/utils/navigation_util.dart'; import 'package:flutter/material.dart'; @@ -53,6 +58,33 @@ class DataSectionWidget extends StatelessWidget { }, ), sectionOptionSpacing, + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: l10n.duplicateCodes, + ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () async { + final List duplicateCodes = + await DeduplicationService.instance.getDuplicateCodes(); + if (duplicateCodes.isEmpty) { + unawaited( + showErrorDialog( + context, + l10n.noDuplicates, + l10n.youveNoDuplicateCodesThatCanBeCleared, + ), + ); + return; + } + await routeToPage( + context, + DuplicateCodePage(duplicateCodes: duplicateCodes), + ); + }, + ), + sectionOptionSpacing, ]); return Column( children: children, diff --git a/auth/lib/ui/settings/data/duplicate_code_page.dart b/auth/lib/ui/settings/data/duplicate_code_page.dart new file mode 100644 index 0000000000..9de8695a52 --- /dev/null +++ b/auth/lib/ui/settings/data/duplicate_code_page.dart @@ -0,0 +1,259 @@ +import 'package:ente_auth/l10n/l10n.dart'; +import 'package:ente_auth/models/code.dart'; +import 'package:ente_auth/services/deduplication_service.dart'; +import 'package:ente_auth/services/local_authentication_service.dart'; +import 'package:ente_auth/store/code_store.dart'; +import 'package:ente_auth/theme/ente_theme.dart'; +import 'package:ente_auth/ui/code_widget.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import 'package:logging/logging.dart'; + +class DuplicateCodePage extends StatefulWidget { + final List duplicateCodes; + const DuplicateCodePage({ + super.key, + required this.duplicateCodes, + }); + + @override + State createState() => _DuplicateCodePageState(); +} + +class _DuplicateCodePageState extends State { + final Logger _logger = Logger("DuplicateCodePage"); + late List _duplicateCodes; + final Set selectedGrids = {}; + + @override + void initState() { + _duplicateCodes = widget.duplicateCodes; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(context.l10n.deduplicateCodes), + elevation: 0, + ), + body: _getBody(), + ); + } + + Widget _getBody() { + final l10n = context.l10n; + return SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + GestureDetector( + onTap: () { + if (selectedGrids.length == _duplicateCodes.length) { + _removeAllGrids(); + } else { + _selectAllGrids(); + } + setState(() {}); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + selectedGrids.length == _duplicateCodes.length + ? l10n.deselectAll + : l10n.selectAll, + style: + Theme.of(context).textTheme.titleMedium!.copyWith( + fontSize: 14, + color: Theme.of(context) + .iconTheme + .color! + .withOpacity(0.7), + ), + ), + const Padding(padding: EdgeInsets.only(left: 4)), + selectedGrids.length == _duplicateCodes.length + ? const Icon( + Icons.check_circle, + size: 24, + ) + : Icon( + Icons.check_circle_outlined, + color: getEnteColorScheme(context).strokeMuted, + size: 24, + ), + ], + ), + ), + ], + ), + ), + ), + const SizedBox(height: 8), + Expanded( + child: ListView.builder( + itemCount: _duplicateCodes.length, + shrinkWrap: true, + itemBuilder: (context, index) { + final List codes = _duplicateCodes[index].codes; + return _getGridView( + codes, + index, + ); + }, + ), + ), + selectedGrids.isEmpty ? const SizedBox.shrink() : _getDeleteButton(), + ], + ), + ); + } + + Widget _getGridView(List code, int itemIndex) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + child: GestureDetector( + onTap: () { + if (selectedGrids.contains(itemIndex)) { + selectedGrids.remove(itemIndex); + } else { + selectedGrids.add(itemIndex); + } + setState(() {}); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "${code[0].issuer}, ${code.length} items", + ), + !selectedGrids.contains(itemIndex) + ? Icon( + Icons.check_circle_outlined, + color: getEnteColorScheme(context).strokeMuted, + size: 24, + ) + : const Icon( + Icons.check_circle, + size: 24, + ), + ], + ), + ), + ), + AlignedGridView.count( + crossAxisCount: (MediaQuery.sizeOf(context).width ~/ 400) + .clamp(1, double.infinity) + .toInt(), + padding: const EdgeInsets.only(bottom: 40), + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + return CodeWidget( + key: ValueKey('${code.hashCode}_$index'), + code[index], + isCompactMode: false, + ); + }, + itemCount: code.length, + ), + ], + ); + } + + Widget _getDeleteButton() { + int selectedItemsCount = 0; + for (int idx = 0; idx < _duplicateCodes.length; idx++) { + if (selectedGrids.contains(idx)) { + selectedItemsCount += _duplicateCodes[idx].codes.length - 1; + } + } + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 20), + child: SizedBox( + width: 400, + child: OutlinedButton( + onPressed: () async { + await deleteDuplicates(selectedItemsCount); + }, + child: Text( + "Delete $selectedItemsCount items", + ), + ), + ), + ); + } + + void _selectAllGrids() { + selectedGrids.clear(); + for (int idx = 0; idx < _duplicateCodes.length; idx++) { + selectedGrids.add(idx); + } + } + + void _removeAllGrids() { + selectedGrids.clear(); + } + + Future deleteDuplicates(int itemCount) async { + bool isAuthSuccessful = + await LocalAuthenticationService.instance.requestLocalAuthentication( + context, + context.l10n.deleteCodeAuthMessage, + ); + if (!isAuthSuccessful) { + return; + } + FocusScope.of(context).requestFocus(); + final l10n = context.l10n; + final String message = "Are you sure you want to trash $itemCount items?"; + await showChoiceActionSheet( + context, + title: l10n.deleteDuplicates, + body: message, + firstButtonLabel: l10n.trash, + isCritical: true, + firstButtonOnTap: () async { + try { + for (int idx = 0; idx < _duplicateCodes.length; idx++) { + if (selectedGrids.contains(idx)) { + final List codes = _duplicateCodes[idx].codes; + for (int i = 1; i < codes.length; i++) { + final display = codes[i].display; + final Code code = codes[i].copyWith( + display: display.copyWith(trashed: true), + ); + await CodeStore.instance.addCode(code); + } + } + } + Navigator.of(context).pop(); + } catch (e) { + _logger.severe('Failed to trash duplicate codes: ${e.toString()}'); + showGenericErrorDialog(context: context, error: e).ignore(); + } + }, + ); + } +} diff --git a/auth/lib/ui/settings/data/export_widget.dart b/auth/lib/ui/settings/data/export_widget.dart index 0df7482898..6d32424d3f 100644 --- a/auth/lib/ui/settings/data/export_widget.dart +++ b/auth/lib/ui/settings/data/export_widget.dart @@ -1,6 +1,5 @@ import 'dart:convert'; import 'dart:io'; - import 'package:ente_auth/core/configuration.dart'; import 'package:ente_auth/l10n/l10n.dart'; import 'package:ente_auth/models/export/ente.dart'; @@ -9,16 +8,15 @@ import 'package:ente_auth/store/code_store.dart'; import 'package:ente_auth/ui/components/buttons/button_widget.dart'; import 'package:ente_auth/ui/components/dialog_widget.dart'; import 'package:ente_auth/ui/components/models/button_type.dart'; +import 'package:ente_auth/ui/settings/data/html_export.dart'; import 'package:ente_auth/utils/dialog_util.dart'; import 'package:ente_auth/utils/platform_util.dart'; import 'package:ente_auth/utils/share_utils.dart'; import 'package:ente_auth/utils/toast_util.dart'; import 'package:ente_crypto_dart/ente_crypto_dart.dart'; import 'package:file_saver/file_saver.dart'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; -import 'package:logging/logging.dart'; import 'package:share_plus/share_plus.dart'; Future handleExportClick(BuildContext context) async { @@ -41,13 +39,22 @@ Future handleExportClick(BuildContext context) async { isInAlert: true, buttonAction: ButtonAction.second, ), + const ButtonWidget( + buttonType: ButtonType.secondary, + labelText: "HTML", + buttonSize: ButtonSize.large, + isInAlert: true, + buttonAction: ButtonAction.third, + ), ], ); if (result?.action != null && result!.action != ButtonAction.cancel) { if (result.action == ButtonAction.first) { await _requestForEncryptionPassword(context); - } else { - await _showExportWarningDialog(context); + } else if (result.action == ButtonAction.second) { + await _showExportWarningDialog(context, "txt"); + } else if (result.action == ButtonAction.third) { + await _showExportWarningDialog(context, "html"); } } } @@ -98,9 +105,8 @@ Future _requestForEncryptionPassword( ), ); // get json value of data - await _exportCodes(context, jsonEncode(data.toJson())); - } catch (e, s) { - Logger("ExportWidget").severe(e, s); + await _exportCodes(context, jsonEncode(data.toJson()), "txt"); + } catch (e) { showToast(context, "Error while exporting codes."); } } @@ -108,26 +114,34 @@ Future _requestForEncryptionPassword( ); } -Future _showExportWarningDialog(BuildContext context) async { +Future _showExportWarningDialog(BuildContext context, String type) async { await showChoiceActionSheet( context, title: context.l10n.warning, body: context.l10n.exportWarningDesc, isCritical: true, firstButtonOnTap: () async { - final data = await _getAuthDataForExport(); - await _exportCodes(context, data); + if (type == "html") { + final data = await generateHtml(context); + await _exportCodes(context, data, type); + } else { + final data = await _getAuthDataForExport(); + await _exportCodes(context, data, type); + } }, secondButtonLabel: context.l10n.cancel, firstButtonLabel: context.l10n.iUnderStand, ); } -Future _exportCodes(BuildContext context, String fileContent) async { +Future _exportCodes( + BuildContext context, + String fileContent, + String extension, +) async { DateTime now = DateTime.now().toUtc(); String formattedDate = DateFormat('yyyy-MM-dd').format(now); String exportFileName = 'ente-auth-codes-$formattedDate'; - String exportFileExtension = 'txt'; final hasAuthenticated = await LocalAuthenticationService.instance .requestLocalAuthentication(context, context.l10n.authToExportCodes); await PlatformUtil.refocusWindows(); @@ -142,14 +156,14 @@ Future _exportCodes(BuildContext context, String fileContent) async { saveAction: () async { await PlatformUtil.shareFile( exportFileName, - exportFileExtension, + extension, CryptoUtil.strToBin(fileContent), MimeType.text, ); }, sendAction: () async { final codeFile = File( - "${Configuration.instance.getTempDirectory()}$exportFileName.$exportFileExtension", + "${Configuration.instance.getTempDirectory()}$exportFileName.$extension", ); if (codeFile.existsSync()) { await codeFile.delete(); diff --git a/auth/lib/ui/settings/data/html_export.dart b/auth/lib/ui/settings/data/html_export.dart new file mode 100644 index 0000000000..705bd8507c --- /dev/null +++ b/auth/lib/ui/settings/data/html_export.dart @@ -0,0 +1,236 @@ +import 'dart:convert'; +import 'dart:ui' as ui; + +import 'package:ente_auth/models/code.dart'; +import 'package:ente_auth/store/code_store.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +Future generateQRImageBase64(String data) async { + final qrPainter = QrPainter( + data: data, + version: QrVersions.auto, + eyeStyle: const QrEyeStyle( + eyeShape: QrEyeShape.square, + color: Colors.black, + ), + dataModuleStyle: const QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.square, + color: Colors.black, + ), + ); + + const size = 250.0; + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + qrPainter.paint(canvas, const Size(size, size)); + final picture = recorder.endRecording(); + final img = await picture.toImage(size.toInt(), size.toInt()); + final byteData = await img.toByteData(format: ui.ImageByteFormat.png); + final pngBytes = byteData!.buffer.asUint8List(); + + return base64Encode(pngBytes); +} + +Future generateOTPEntryHtml( + Code code, + BuildContext context, +) async { + final qrBase64 = await generateQRImageBase64(code.rawData); + String notes = code.display.note; + if (notes.isNotEmpty) { + notes = 'Note: $notes'; + } + return ''' + + + ${code.issuer} + ${code.account} + + Type: ${code.type.name} + Algorithm: ${code.algorithm.name} + Digits: ${code.digits} + Recovery Code: ${code.secret} + $notes + + + + + + + '''; +} + +Future generateHtml(BuildContext context) async { + DateTime now = DateTime.now().toUtc(); + String formattedDate = DateFormat('d MMMM, yyyy').format(now); + final allCodes = await CodeStore.instance.getAllCodes(); + final List enteries = []; + + for (final code in allCodes) { + if (code.hasError) continue; + final entry = await generateOTPEntryHtml(code, context); + enteries.add(entry); + } + + return ''' + + + + + + + + Ente Auth + OTP Data Export + $formattedDate + + + + + ${enteries.join('')} + + + + + + + + +
codes, CodeSortKey sortKey) { switch (sortKey) { case CodeSortKey.issuerName: - codes.sort((a, b) => a.issuer.compareTo(b.issuer)); + codes.sort((a, b) => compareAsciiLowerCaseNatural(a.issuer, b.issuer)); break; case CodeSortKey.accountName: - codes.sort((a, b) => a.account.compareTo(b.account)); + codes + .sort((a, b) => compareAsciiLowerCaseNatural(a.account, b.account)); break; case CodeSortKey.mostFrequentlyUsed: codes.sort((a, b) => b.display.tapCount.compareTo(a.display.tapCount)); diff --git a/auth/lib/ui/reorder_codes_page.dart b/auth/lib/ui/reorder_codes_page.dart index 7d79949b0f..4dd693e8c3 100644 --- a/auth/lib/ui/reorder_codes_page.dart +++ b/auth/lib/ui/reorder_codes_page.dart @@ -3,6 +3,7 @@ import 'package:ente_auth/l10n/l10n.dart'; import 'package:ente_auth/models/code.dart'; import 'package:ente_auth/services/preference_service.dart'; import 'package:ente_auth/store/code_store.dart'; +import 'package:ente_auth/theme/ente_theme.dart'; import 'package:ente_auth/ui/code_widget.dart'; import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; @@ -16,71 +17,71 @@ class ReorderCodesPage extends StatefulWidget { } class _ReorderCodesPageState extends State { - int selectedSortOption = 2; + bool hasChanged = false; final logger = Logger('ReorderCodesPage'); @override Widget build(BuildContext context) { final bool isCompactMode = PreferenceService.instance.isCompactMode(); - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, result) async { - if (!didPop) { - final hasSaved = await saveUpadedIndexes(); - if (hasSaved) { + return Scaffold( + appBar: AppBar( + title: const Text("Custom order"), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () async { Navigator.of(context).pop(); - } - } - }, - child: Scaffold( - appBar: AppBar( - title: Text(context.l10n.editOrder), - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () async { + }, + ), + actions: [ + GestureDetector( + onTap: () async { final hasSaved = await saveUpadedIndexes(); if (hasSaved) { Navigator.of(context).pop(); } }, + child: Padding( + padding: const EdgeInsets.only(right: 20), + child: Text( + context.l10n.save, + style: TextStyle( + color: hasChanged + ? getEnteColorScheme(context).textBase + : getEnteColorScheme(context).strokeMuted, + ), + ), + ), ), - ), - body: ReorderableListView( - buildDefaultDragHandles: false, - proxyDecorator: - (Widget child, int index, Animation animation) { - return AnimatedBuilder( - animation: animation, - builder: (BuildContext context, _) { - final animValue = Curves.easeInOut.transform(animation.value); - final scale = lerpDouble(1, 1.05, animValue)!; - return Transform.scale(scale: scale, child: child); - }, - ); - }, - children: [ - for (final code in widget.codes) - selectedSortOption == 2 - ? ReorderableDragStartListener( - key: ValueKey('${code.hashCode}_${code.generatedID}'), - index: widget.codes.indexOf(code), - child: CodeWidget( - key: ValueKey(code.generatedID), - code, - isCompactMode: isCompactMode, - ), - ) - : CodeWidget( - key: ValueKey('${code.hashCode}_${code.generatedID}'), - code, - isCompactMode: isCompactMode, - ), - ], - onReorder: (oldIndex, newIndex) { - if (selectedSortOption == 2) updateCodeIndex(oldIndex, newIndex); - }, - ), + ], + ), + body: ReorderableListView( + buildDefaultDragHandles: false, + proxyDecorator: (Widget child, int index, Animation animation) { + return AnimatedBuilder( + animation: animation, + builder: (BuildContext context, _) { + final animValue = Curves.easeInOut.transform(animation.value); + final scale = lerpDouble(1, 1.05, animValue)!; + return Transform.scale(scale: scale, child: child); + }, + ); + }, + children: [ + for (final code in widget.codes) + ReorderableDragStartListener( + key: ValueKey('${code.hashCode}_${code.generatedID}'), + index: widget.codes.indexOf(code), + child: CodeWidget( + key: ValueKey(code.generatedID), + code, + isCompactMode: isCompactMode, + ), + ), + ], + onReorder: (oldIndex, newIndex) { + updateCodeIndex(oldIndex, newIndex); + }, ), ); } @@ -97,6 +98,7 @@ class _ReorderCodesPageState extends State { if (oldIndex < newIndex) newIndex -= 1; final Code code = widget.codes.removeAt(oldIndex); widget.codes.insert(newIndex, code); + hasChanged = true; }); } } diff --git a/auth/lib/ui/settings/data/data_section_widget.dart b/auth/lib/ui/settings/data/data_section_widget.dart index f32739d239..5665d58532 100644 --- a/auth/lib/ui/settings/data/data_section_widget.dart +++ b/auth/lib/ui/settings/data/data_section_widget.dart @@ -1,11 +1,16 @@ +import 'dart:async'; + import 'package:ente_auth/l10n/l10n.dart'; +import 'package:ente_auth/services/deduplication_service.dart'; import 'package:ente_auth/theme/ente_theme.dart'; import 'package:ente_auth/ui/components/captioned_text_widget.dart'; import 'package:ente_auth/ui/components/expandable_menu_item_widget.dart'; import 'package:ente_auth/ui/components/menu_item_widget.dart'; import 'package:ente_auth/ui/settings/common_settings.dart'; +import 'package:ente_auth/ui/settings/data/duplicate_code_page.dart'; import 'package:ente_auth/ui/settings/data/export_widget.dart'; import 'package:ente_auth/ui/settings/data/import_page.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; import 'package:ente_auth/utils/navigation_util.dart'; import 'package:flutter/material.dart'; @@ -53,6 +58,33 @@ class DataSectionWidget extends StatelessWidget { }, ), sectionOptionSpacing, + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: l10n.duplicateCodes, + ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () async { + final List duplicateCodes = + await DeduplicationService.instance.getDuplicateCodes(); + if (duplicateCodes.isEmpty) { + unawaited( + showErrorDialog( + context, + l10n.noDuplicates, + l10n.youveNoDuplicateCodesThatCanBeCleared, + ), + ); + return; + } + await routeToPage( + context, + DuplicateCodePage(duplicateCodes: duplicateCodes), + ); + }, + ), + sectionOptionSpacing, ]); return Column( children: children, diff --git a/auth/lib/ui/settings/data/duplicate_code_page.dart b/auth/lib/ui/settings/data/duplicate_code_page.dart new file mode 100644 index 0000000000..9de8695a52 --- /dev/null +++ b/auth/lib/ui/settings/data/duplicate_code_page.dart @@ -0,0 +1,259 @@ +import 'package:ente_auth/l10n/l10n.dart'; +import 'package:ente_auth/models/code.dart'; +import 'package:ente_auth/services/deduplication_service.dart'; +import 'package:ente_auth/services/local_authentication_service.dart'; +import 'package:ente_auth/store/code_store.dart'; +import 'package:ente_auth/theme/ente_theme.dart'; +import 'package:ente_auth/ui/code_widget.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import 'package:logging/logging.dart'; + +class DuplicateCodePage extends StatefulWidget { + final List duplicateCodes; + const DuplicateCodePage({ + super.key, + required this.duplicateCodes, + }); + + @override + State createState() => _DuplicateCodePageState(); +} + +class _DuplicateCodePageState extends State { + final Logger _logger = Logger("DuplicateCodePage"); + late List _duplicateCodes; + final Set selectedGrids = {}; + + @override + void initState() { + _duplicateCodes = widget.duplicateCodes; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(context.l10n.deduplicateCodes), + elevation: 0, + ), + body: _getBody(), + ); + } + + Widget _getBody() { + final l10n = context.l10n; + return SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + GestureDetector( + onTap: () { + if (selectedGrids.length == _duplicateCodes.length) { + _removeAllGrids(); + } else { + _selectAllGrids(); + } + setState(() {}); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + selectedGrids.length == _duplicateCodes.length + ? l10n.deselectAll + : l10n.selectAll, + style: + Theme.of(context).textTheme.titleMedium!.copyWith( + fontSize: 14, + color: Theme.of(context) + .iconTheme + .color! + .withOpacity(0.7), + ), + ), + const Padding(padding: EdgeInsets.only(left: 4)), + selectedGrids.length == _duplicateCodes.length + ? const Icon( + Icons.check_circle, + size: 24, + ) + : Icon( + Icons.check_circle_outlined, + color: getEnteColorScheme(context).strokeMuted, + size: 24, + ), + ], + ), + ), + ], + ), + ), + ), + const SizedBox(height: 8), + Expanded( + child: ListView.builder( + itemCount: _duplicateCodes.length, + shrinkWrap: true, + itemBuilder: (context, index) { + final List codes = _duplicateCodes[index].codes; + return _getGridView( + codes, + index, + ); + }, + ), + ), + selectedGrids.isEmpty ? const SizedBox.shrink() : _getDeleteButton(), + ], + ), + ); + } + + Widget _getGridView(List code, int itemIndex) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + child: GestureDetector( + onTap: () { + if (selectedGrids.contains(itemIndex)) { + selectedGrids.remove(itemIndex); + } else { + selectedGrids.add(itemIndex); + } + setState(() {}); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "${code[0].issuer}, ${code.length} items", + ), + !selectedGrids.contains(itemIndex) + ? Icon( + Icons.check_circle_outlined, + color: getEnteColorScheme(context).strokeMuted, + size: 24, + ) + : const Icon( + Icons.check_circle, + size: 24, + ), + ], + ), + ), + ), + AlignedGridView.count( + crossAxisCount: (MediaQuery.sizeOf(context).width ~/ 400) + .clamp(1, double.infinity) + .toInt(), + padding: const EdgeInsets.only(bottom: 40), + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + return CodeWidget( + key: ValueKey('${code.hashCode}_$index'), + code[index], + isCompactMode: false, + ); + }, + itemCount: code.length, + ), + ], + ); + } + + Widget _getDeleteButton() { + int selectedItemsCount = 0; + for (int idx = 0; idx < _duplicateCodes.length; idx++) { + if (selectedGrids.contains(idx)) { + selectedItemsCount += _duplicateCodes[idx].codes.length - 1; + } + } + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 20), + child: SizedBox( + width: 400, + child: OutlinedButton( + onPressed: () async { + await deleteDuplicates(selectedItemsCount); + }, + child: Text( + "Delete $selectedItemsCount items", + ), + ), + ), + ); + } + + void _selectAllGrids() { + selectedGrids.clear(); + for (int idx = 0; idx < _duplicateCodes.length; idx++) { + selectedGrids.add(idx); + } + } + + void _removeAllGrids() { + selectedGrids.clear(); + } + + Future deleteDuplicates(int itemCount) async { + bool isAuthSuccessful = + await LocalAuthenticationService.instance.requestLocalAuthentication( + context, + context.l10n.deleteCodeAuthMessage, + ); + if (!isAuthSuccessful) { + return; + } + FocusScope.of(context).requestFocus(); + final l10n = context.l10n; + final String message = "Are you sure you want to trash $itemCount items?"; + await showChoiceActionSheet( + context, + title: l10n.deleteDuplicates, + body: message, + firstButtonLabel: l10n.trash, + isCritical: true, + firstButtonOnTap: () async { + try { + for (int idx = 0; idx < _duplicateCodes.length; idx++) { + if (selectedGrids.contains(idx)) { + final List codes = _duplicateCodes[idx].codes; + for (int i = 1; i < codes.length; i++) { + final display = codes[i].display; + final Code code = codes[i].copyWith( + display: display.copyWith(trashed: true), + ); + await CodeStore.instance.addCode(code); + } + } + } + Navigator.of(context).pop(); + } catch (e) { + _logger.severe('Failed to trash duplicate codes: ${e.toString()}'); + showGenericErrorDialog(context: context, error: e).ignore(); + } + }, + ); + } +} diff --git a/auth/lib/ui/settings/data/export_widget.dart b/auth/lib/ui/settings/data/export_widget.dart index 0df7482898..6d32424d3f 100644 --- a/auth/lib/ui/settings/data/export_widget.dart +++ b/auth/lib/ui/settings/data/export_widget.dart @@ -1,6 +1,5 @@ import 'dart:convert'; import 'dart:io'; - import 'package:ente_auth/core/configuration.dart'; import 'package:ente_auth/l10n/l10n.dart'; import 'package:ente_auth/models/export/ente.dart'; @@ -9,16 +8,15 @@ import 'package:ente_auth/store/code_store.dart'; import 'package:ente_auth/ui/components/buttons/button_widget.dart'; import 'package:ente_auth/ui/components/dialog_widget.dart'; import 'package:ente_auth/ui/components/models/button_type.dart'; +import 'package:ente_auth/ui/settings/data/html_export.dart'; import 'package:ente_auth/utils/dialog_util.dart'; import 'package:ente_auth/utils/platform_util.dart'; import 'package:ente_auth/utils/share_utils.dart'; import 'package:ente_auth/utils/toast_util.dart'; import 'package:ente_crypto_dart/ente_crypto_dart.dart'; import 'package:file_saver/file_saver.dart'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; -import 'package:logging/logging.dart'; import 'package:share_plus/share_plus.dart'; Future handleExportClick(BuildContext context) async { @@ -41,13 +39,22 @@ Future handleExportClick(BuildContext context) async { isInAlert: true, buttonAction: ButtonAction.second, ), + const ButtonWidget( + buttonType: ButtonType.secondary, + labelText: "HTML", + buttonSize: ButtonSize.large, + isInAlert: true, + buttonAction: ButtonAction.third, + ), ], ); if (result?.action != null && result!.action != ButtonAction.cancel) { if (result.action == ButtonAction.first) { await _requestForEncryptionPassword(context); - } else { - await _showExportWarningDialog(context); + } else if (result.action == ButtonAction.second) { + await _showExportWarningDialog(context, "txt"); + } else if (result.action == ButtonAction.third) { + await _showExportWarningDialog(context, "html"); } } } @@ -98,9 +105,8 @@ Future _requestForEncryptionPassword( ), ); // get json value of data - await _exportCodes(context, jsonEncode(data.toJson())); - } catch (e, s) { - Logger("ExportWidget").severe(e, s); + await _exportCodes(context, jsonEncode(data.toJson()), "txt"); + } catch (e) { showToast(context, "Error while exporting codes."); } } @@ -108,26 +114,34 @@ Future _requestForEncryptionPassword( ); } -Future _showExportWarningDialog(BuildContext context) async { +Future _showExportWarningDialog(BuildContext context, String type) async { await showChoiceActionSheet( context, title: context.l10n.warning, body: context.l10n.exportWarningDesc, isCritical: true, firstButtonOnTap: () async { - final data = await _getAuthDataForExport(); - await _exportCodes(context, data); + if (type == "html") { + final data = await generateHtml(context); + await _exportCodes(context, data, type); + } else { + final data = await _getAuthDataForExport(); + await _exportCodes(context, data, type); + } }, secondButtonLabel: context.l10n.cancel, firstButtonLabel: context.l10n.iUnderStand, ); } -Future _exportCodes(BuildContext context, String fileContent) async { +Future _exportCodes( + BuildContext context, + String fileContent, + String extension, +) async { DateTime now = DateTime.now().toUtc(); String formattedDate = DateFormat('yyyy-MM-dd').format(now); String exportFileName = 'ente-auth-codes-$formattedDate'; - String exportFileExtension = 'txt'; final hasAuthenticated = await LocalAuthenticationService.instance .requestLocalAuthentication(context, context.l10n.authToExportCodes); await PlatformUtil.refocusWindows(); @@ -142,14 +156,14 @@ Future _exportCodes(BuildContext context, String fileContent) async { saveAction: () async { await PlatformUtil.shareFile( exportFileName, - exportFileExtension, + extension, CryptoUtil.strToBin(fileContent), MimeType.text, ); }, sendAction: () async { final codeFile = File( - "${Configuration.instance.getTempDirectory()}$exportFileName.$exportFileExtension", + "${Configuration.instance.getTempDirectory()}$exportFileName.$extension", ); if (codeFile.existsSync()) { await codeFile.delete(); diff --git a/auth/lib/ui/settings/data/html_export.dart b/auth/lib/ui/settings/data/html_export.dart new file mode 100644 index 0000000000..705bd8507c --- /dev/null +++ b/auth/lib/ui/settings/data/html_export.dart @@ -0,0 +1,236 @@ +import 'dart:convert'; +import 'dart:ui' as ui; + +import 'package:ente_auth/models/code.dart'; +import 'package:ente_auth/store/code_store.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +Future generateQRImageBase64(String data) async { + final qrPainter = QrPainter( + data: data, + version: QrVersions.auto, + eyeStyle: const QrEyeStyle( + eyeShape: QrEyeShape.square, + color: Colors.black, + ), + dataModuleStyle: const QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.square, + color: Colors.black, + ), + ); + + const size = 250.0; + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + qrPainter.paint(canvas, const Size(size, size)); + final picture = recorder.endRecording(); + final img = await picture.toImage(size.toInt(), size.toInt()); + final byteData = await img.toByteData(format: ui.ImageByteFormat.png); + final pngBytes = byteData!.buffer.asUint8List(); + + return base64Encode(pngBytes); +} + +Future generateOTPEntryHtml( + Code code, + BuildContext context, +) async { + final qrBase64 = await generateQRImageBase64(code.rawData); + String notes = code.display.note; + if (notes.isNotEmpty) { + notes = 'Note: $notes'; + } + return ''' + + + ${code.issuer} + ${code.account} + + Type: ${code.type.name} + Algorithm: ${code.algorithm.name} + Digits: ${code.digits} + Recovery Code: ${code.secret} + $notes + + + + + + + '''; +} + +Future generateHtml(BuildContext context) async { + DateTime now = DateTime.now().toUtc(); + String formattedDate = DateFormat('d MMMM, yyyy').format(now); + final allCodes = await CodeStore.instance.getAllCodes(); + final List enteries = []; + + for (final code in allCodes) { + if (code.hasError) continue; + final entry = await generateOTPEntryHtml(code, context); + enteries.add(entry); + } + + return ''' + + + + + + + + Ente Auth + OTP Data Export + $formattedDate + + + + + ${enteries.join('')} + + + + + + + + +
codes = _duplicateCodes[index].codes; + return _getGridView( + codes, + index, + ); + }, + ), + ), + selectedGrids.isEmpty ? const SizedBox.shrink() : _getDeleteButton(), + ], + ), + ); + } + + Widget _getGridView(List code, int itemIndex) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + child: GestureDetector( + onTap: () { + if (selectedGrids.contains(itemIndex)) { + selectedGrids.remove(itemIndex); + } else { + selectedGrids.add(itemIndex); + } + setState(() {}); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "${code[0].issuer}, ${code.length} items", + ), + !selectedGrids.contains(itemIndex) + ? Icon( + Icons.check_circle_outlined, + color: getEnteColorScheme(context).strokeMuted, + size: 24, + ) + : const Icon( + Icons.check_circle, + size: 24, + ), + ], + ), + ), + ), + AlignedGridView.count( + crossAxisCount: (MediaQuery.sizeOf(context).width ~/ 400) + .clamp(1, double.infinity) + .toInt(), + padding: const EdgeInsets.only(bottom: 40), + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + return CodeWidget( + key: ValueKey('${code.hashCode}_$index'), + code[index], + isCompactMode: false, + ); + }, + itemCount: code.length, + ), + ], + ); + } + + Widget _getDeleteButton() { + int selectedItemsCount = 0; + for (int idx = 0; idx < _duplicateCodes.length; idx++) { + if (selectedGrids.contains(idx)) { + selectedItemsCount += _duplicateCodes[idx].codes.length - 1; + } + } + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 20), + child: SizedBox( + width: 400, + child: OutlinedButton( + onPressed: () async { + await deleteDuplicates(selectedItemsCount); + }, + child: Text( + "Delete $selectedItemsCount items", + ), + ), + ), + ); + } + + void _selectAllGrids() { + selectedGrids.clear(); + for (int idx = 0; idx < _duplicateCodes.length; idx++) { + selectedGrids.add(idx); + } + } + + void _removeAllGrids() { + selectedGrids.clear(); + } + + Future deleteDuplicates(int itemCount) async { + bool isAuthSuccessful = + await LocalAuthenticationService.instance.requestLocalAuthentication( + context, + context.l10n.deleteCodeAuthMessage, + ); + if (!isAuthSuccessful) { + return; + } + FocusScope.of(context).requestFocus(); + final l10n = context.l10n; + final String message = "Are you sure you want to trash $itemCount items?"; + await showChoiceActionSheet( + context, + title: l10n.deleteDuplicates, + body: message, + firstButtonLabel: l10n.trash, + isCritical: true, + firstButtonOnTap: () async { + try { + for (int idx = 0; idx < _duplicateCodes.length; idx++) { + if (selectedGrids.contains(idx)) { + final List codes = _duplicateCodes[idx].codes; + for (int i = 1; i < codes.length; i++) { + final display = codes[i].display; + final Code code = codes[i].copyWith( + display: display.copyWith(trashed: true), + ); + await CodeStore.instance.addCode(code); + } + } + } + Navigator.of(context).pop(); + } catch (e) { + _logger.severe('Failed to trash duplicate codes: ${e.toString()}'); + showGenericErrorDialog(context: context, error: e).ignore(); + } + }, + ); + } +} diff --git a/auth/lib/ui/settings/data/export_widget.dart b/auth/lib/ui/settings/data/export_widget.dart index 0df7482898..6d32424d3f 100644 --- a/auth/lib/ui/settings/data/export_widget.dart +++ b/auth/lib/ui/settings/data/export_widget.dart @@ -1,6 +1,5 @@ import 'dart:convert'; import 'dart:io'; - import 'package:ente_auth/core/configuration.dart'; import 'package:ente_auth/l10n/l10n.dart'; import 'package:ente_auth/models/export/ente.dart'; @@ -9,16 +8,15 @@ import 'package:ente_auth/store/code_store.dart'; import 'package:ente_auth/ui/components/buttons/button_widget.dart'; import 'package:ente_auth/ui/components/dialog_widget.dart'; import 'package:ente_auth/ui/components/models/button_type.dart'; +import 'package:ente_auth/ui/settings/data/html_export.dart'; import 'package:ente_auth/utils/dialog_util.dart'; import 'package:ente_auth/utils/platform_util.dart'; import 'package:ente_auth/utils/share_utils.dart'; import 'package:ente_auth/utils/toast_util.dart'; import 'package:ente_crypto_dart/ente_crypto_dart.dart'; import 'package:file_saver/file_saver.dart'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; -import 'package:logging/logging.dart'; import 'package:share_plus/share_plus.dart'; Future handleExportClick(BuildContext context) async { @@ -41,13 +39,22 @@ Future handleExportClick(BuildContext context) async { isInAlert: true, buttonAction: ButtonAction.second, ), + const ButtonWidget( + buttonType: ButtonType.secondary, + labelText: "HTML", + buttonSize: ButtonSize.large, + isInAlert: true, + buttonAction: ButtonAction.third, + ), ], ); if (result?.action != null && result!.action != ButtonAction.cancel) { if (result.action == ButtonAction.first) { await _requestForEncryptionPassword(context); - } else { - await _showExportWarningDialog(context); + } else if (result.action == ButtonAction.second) { + await _showExportWarningDialog(context, "txt"); + } else if (result.action == ButtonAction.third) { + await _showExportWarningDialog(context, "html"); } } } @@ -98,9 +105,8 @@ Future _requestForEncryptionPassword( ), ); // get json value of data - await _exportCodes(context, jsonEncode(data.toJson())); - } catch (e, s) { - Logger("ExportWidget").severe(e, s); + await _exportCodes(context, jsonEncode(data.toJson()), "txt"); + } catch (e) { showToast(context, "Error while exporting codes."); } } @@ -108,26 +114,34 @@ Future _requestForEncryptionPassword( ); } -Future _showExportWarningDialog(BuildContext context) async { +Future _showExportWarningDialog(BuildContext context, String type) async { await showChoiceActionSheet( context, title: context.l10n.warning, body: context.l10n.exportWarningDesc, isCritical: true, firstButtonOnTap: () async { - final data = await _getAuthDataForExport(); - await _exportCodes(context, data); + if (type == "html") { + final data = await generateHtml(context); + await _exportCodes(context, data, type); + } else { + final data = await _getAuthDataForExport(); + await _exportCodes(context, data, type); + } }, secondButtonLabel: context.l10n.cancel, firstButtonLabel: context.l10n.iUnderStand, ); } -Future _exportCodes(BuildContext context, String fileContent) async { +Future _exportCodes( + BuildContext context, + String fileContent, + String extension, +) async { DateTime now = DateTime.now().toUtc(); String formattedDate = DateFormat('yyyy-MM-dd').format(now); String exportFileName = 'ente-auth-codes-$formattedDate'; - String exportFileExtension = 'txt'; final hasAuthenticated = await LocalAuthenticationService.instance .requestLocalAuthentication(context, context.l10n.authToExportCodes); await PlatformUtil.refocusWindows(); @@ -142,14 +156,14 @@ Future _exportCodes(BuildContext context, String fileContent) async { saveAction: () async { await PlatformUtil.shareFile( exportFileName, - exportFileExtension, + extension, CryptoUtil.strToBin(fileContent), MimeType.text, ); }, sendAction: () async { final codeFile = File( - "${Configuration.instance.getTempDirectory()}$exportFileName.$exportFileExtension", + "${Configuration.instance.getTempDirectory()}$exportFileName.$extension", ); if (codeFile.existsSync()) { await codeFile.delete(); diff --git a/auth/lib/ui/settings/data/html_export.dart b/auth/lib/ui/settings/data/html_export.dart new file mode 100644 index 0000000000..705bd8507c --- /dev/null +++ b/auth/lib/ui/settings/data/html_export.dart @@ -0,0 +1,236 @@ +import 'dart:convert'; +import 'dart:ui' as ui; + +import 'package:ente_auth/models/code.dart'; +import 'package:ente_auth/store/code_store.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +Future generateQRImageBase64(String data) async { + final qrPainter = QrPainter( + data: data, + version: QrVersions.auto, + eyeStyle: const QrEyeStyle( + eyeShape: QrEyeShape.square, + color: Colors.black, + ), + dataModuleStyle: const QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.square, + color: Colors.black, + ), + ); + + const size = 250.0; + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + qrPainter.paint(canvas, const Size(size, size)); + final picture = recorder.endRecording(); + final img = await picture.toImage(size.toInt(), size.toInt()); + final byteData = await img.toByteData(format: ui.ImageByteFormat.png); + final pngBytes = byteData!.buffer.asUint8List(); + + return base64Encode(pngBytes); +} + +Future generateOTPEntryHtml( + Code code, + BuildContext context, +) async { + final qrBase64 = await generateQRImageBase64(code.rawData); + String notes = code.display.note; + if (notes.isNotEmpty) { + notes = 'Note: $notes'; + } + return ''' + + + ${code.issuer} + ${code.account} + + Type: ${code.type.name} + Algorithm: ${code.algorithm.name} + Digits: ${code.digits} + Recovery Code: ${code.secret} + $notes + + + + + + + '''; +} + +Future generateHtml(BuildContext context) async { + DateTime now = DateTime.now().toUtc(); + String formattedDate = DateFormat('d MMMM, yyyy').format(now); + final allCodes = await CodeStore.instance.getAllCodes(); + final List enteries = []; + + for (final code in allCodes) { + if (code.hasError) continue; + final entry = await generateOTPEntryHtml(code, context); + enteries.add(entry); + } + + return ''' + + + + + + + + Ente Auth + OTP Data Export + $formattedDate + + + + + ${enteries.join('')} + + + + + + + + +
code, int itemIndex) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + child: GestureDetector( + onTap: () { + if (selectedGrids.contains(itemIndex)) { + selectedGrids.remove(itemIndex); + } else { + selectedGrids.add(itemIndex); + } + setState(() {}); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "${code[0].issuer}, ${code.length} items", + ), + !selectedGrids.contains(itemIndex) + ? Icon( + Icons.check_circle_outlined, + color: getEnteColorScheme(context).strokeMuted, + size: 24, + ) + : const Icon( + Icons.check_circle, + size: 24, + ), + ], + ), + ), + ), + AlignedGridView.count( + crossAxisCount: (MediaQuery.sizeOf(context).width ~/ 400) + .clamp(1, double.infinity) + .toInt(), + padding: const EdgeInsets.only(bottom: 40), + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + return CodeWidget( + key: ValueKey('${code.hashCode}_$index'), + code[index], + isCompactMode: false, + ); + }, + itemCount: code.length, + ), + ], + ); + } + + Widget _getDeleteButton() { + int selectedItemsCount = 0; + for (int idx = 0; idx < _duplicateCodes.length; idx++) { + if (selectedGrids.contains(idx)) { + selectedItemsCount += _duplicateCodes[idx].codes.length - 1; + } + } + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 20), + child: SizedBox( + width: 400, + child: OutlinedButton( + onPressed: () async { + await deleteDuplicates(selectedItemsCount); + }, + child: Text( + "Delete $selectedItemsCount items", + ), + ), + ), + ); + } + + void _selectAllGrids() { + selectedGrids.clear(); + for (int idx = 0; idx < _duplicateCodes.length; idx++) { + selectedGrids.add(idx); + } + } + + void _removeAllGrids() { + selectedGrids.clear(); + } + + Future deleteDuplicates(int itemCount) async { + bool isAuthSuccessful = + await LocalAuthenticationService.instance.requestLocalAuthentication( + context, + context.l10n.deleteCodeAuthMessage, + ); + if (!isAuthSuccessful) { + return; + } + FocusScope.of(context).requestFocus(); + final l10n = context.l10n; + final String message = "Are you sure you want to trash $itemCount items?"; + await showChoiceActionSheet( + context, + title: l10n.deleteDuplicates, + body: message, + firstButtonLabel: l10n.trash, + isCritical: true, + firstButtonOnTap: () async { + try { + for (int idx = 0; idx < _duplicateCodes.length; idx++) { + if (selectedGrids.contains(idx)) { + final List codes = _duplicateCodes[idx].codes; + for (int i = 1; i < codes.length; i++) { + final display = codes[i].display; + final Code code = codes[i].copyWith( + display: display.copyWith(trashed: true), + ); + await CodeStore.instance.addCode(code); + } + } + } + Navigator.of(context).pop(); + } catch (e) { + _logger.severe('Failed to trash duplicate codes: ${e.toString()}'); + showGenericErrorDialog(context: context, error: e).ignore(); + } + }, + ); + } +} diff --git a/auth/lib/ui/settings/data/export_widget.dart b/auth/lib/ui/settings/data/export_widget.dart index 0df7482898..6d32424d3f 100644 --- a/auth/lib/ui/settings/data/export_widget.dart +++ b/auth/lib/ui/settings/data/export_widget.dart @@ -1,6 +1,5 @@ import 'dart:convert'; import 'dart:io'; - import 'package:ente_auth/core/configuration.dart'; import 'package:ente_auth/l10n/l10n.dart'; import 'package:ente_auth/models/export/ente.dart'; @@ -9,16 +8,15 @@ import 'package:ente_auth/store/code_store.dart'; import 'package:ente_auth/ui/components/buttons/button_widget.dart'; import 'package:ente_auth/ui/components/dialog_widget.dart'; import 'package:ente_auth/ui/components/models/button_type.dart'; +import 'package:ente_auth/ui/settings/data/html_export.dart'; import 'package:ente_auth/utils/dialog_util.dart'; import 'package:ente_auth/utils/platform_util.dart'; import 'package:ente_auth/utils/share_utils.dart'; import 'package:ente_auth/utils/toast_util.dart'; import 'package:ente_crypto_dart/ente_crypto_dart.dart'; import 'package:file_saver/file_saver.dart'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; -import 'package:logging/logging.dart'; import 'package:share_plus/share_plus.dart'; Future handleExportClick(BuildContext context) async { @@ -41,13 +39,22 @@ Future handleExportClick(BuildContext context) async { isInAlert: true, buttonAction: ButtonAction.second, ), + const ButtonWidget( + buttonType: ButtonType.secondary, + labelText: "HTML", + buttonSize: ButtonSize.large, + isInAlert: true, + buttonAction: ButtonAction.third, + ), ], ); if (result?.action != null && result!.action != ButtonAction.cancel) { if (result.action == ButtonAction.first) { await _requestForEncryptionPassword(context); - } else { - await _showExportWarningDialog(context); + } else if (result.action == ButtonAction.second) { + await _showExportWarningDialog(context, "txt"); + } else if (result.action == ButtonAction.third) { + await _showExportWarningDialog(context, "html"); } } } @@ -98,9 +105,8 @@ Future _requestForEncryptionPassword( ), ); // get json value of data - await _exportCodes(context, jsonEncode(data.toJson())); - } catch (e, s) { - Logger("ExportWidget").severe(e, s); + await _exportCodes(context, jsonEncode(data.toJson()), "txt"); + } catch (e) { showToast(context, "Error while exporting codes."); } } @@ -108,26 +114,34 @@ Future _requestForEncryptionPassword( ); } -Future _showExportWarningDialog(BuildContext context) async { +Future _showExportWarningDialog(BuildContext context, String type) async { await showChoiceActionSheet( context, title: context.l10n.warning, body: context.l10n.exportWarningDesc, isCritical: true, firstButtonOnTap: () async { - final data = await _getAuthDataForExport(); - await _exportCodes(context, data); + if (type == "html") { + final data = await generateHtml(context); + await _exportCodes(context, data, type); + } else { + final data = await _getAuthDataForExport(); + await _exportCodes(context, data, type); + } }, secondButtonLabel: context.l10n.cancel, firstButtonLabel: context.l10n.iUnderStand, ); } -Future _exportCodes(BuildContext context, String fileContent) async { +Future _exportCodes( + BuildContext context, + String fileContent, + String extension, +) async { DateTime now = DateTime.now().toUtc(); String formattedDate = DateFormat('yyyy-MM-dd').format(now); String exportFileName = 'ente-auth-codes-$formattedDate'; - String exportFileExtension = 'txt'; final hasAuthenticated = await LocalAuthenticationService.instance .requestLocalAuthentication(context, context.l10n.authToExportCodes); await PlatformUtil.refocusWindows(); @@ -142,14 +156,14 @@ Future _exportCodes(BuildContext context, String fileContent) async { saveAction: () async { await PlatformUtil.shareFile( exportFileName, - exportFileExtension, + extension, CryptoUtil.strToBin(fileContent), MimeType.text, ); }, sendAction: () async { final codeFile = File( - "${Configuration.instance.getTempDirectory()}$exportFileName.$exportFileExtension", + "${Configuration.instance.getTempDirectory()}$exportFileName.$extension", ); if (codeFile.existsSync()) { await codeFile.delete(); diff --git a/auth/lib/ui/settings/data/html_export.dart b/auth/lib/ui/settings/data/html_export.dart new file mode 100644 index 0000000000..705bd8507c --- /dev/null +++ b/auth/lib/ui/settings/data/html_export.dart @@ -0,0 +1,236 @@ +import 'dart:convert'; +import 'dart:ui' as ui; + +import 'package:ente_auth/models/code.dart'; +import 'package:ente_auth/store/code_store.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +Future generateQRImageBase64(String data) async { + final qrPainter = QrPainter( + data: data, + version: QrVersions.auto, + eyeStyle: const QrEyeStyle( + eyeShape: QrEyeShape.square, + color: Colors.black, + ), + dataModuleStyle: const QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.square, + color: Colors.black, + ), + ); + + const size = 250.0; + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + qrPainter.paint(canvas, const Size(size, size)); + final picture = recorder.endRecording(); + final img = await picture.toImage(size.toInt(), size.toInt()); + final byteData = await img.toByteData(format: ui.ImageByteFormat.png); + final pngBytes = byteData!.buffer.asUint8List(); + + return base64Encode(pngBytes); +} + +Future generateOTPEntryHtml( + Code code, + BuildContext context, +) async { + final qrBase64 = await generateQRImageBase64(code.rawData); + String notes = code.display.note; + if (notes.isNotEmpty) { + notes = 'Note: $notes'; + } + return ''' + + + ${code.issuer} + ${code.account} + + Type: ${code.type.name} + Algorithm: ${code.algorithm.name} + Digits: ${code.digits} + Recovery Code: ${code.secret} + $notes + + + + + + + '''; +} + +Future generateHtml(BuildContext context) async { + DateTime now = DateTime.now().toUtc(); + String formattedDate = DateFormat('d MMMM, yyyy').format(now); + final allCodes = await CodeStore.instance.getAllCodes(); + final List enteries = []; + + for (final code in allCodes) { + if (code.hasError) continue; + final entry = await generateOTPEntryHtml(code, context); + enteries.add(entry); + } + + return ''' + + + + + + + + Ente Auth + OTP Data Export + $formattedDate + + + + + ${enteries.join('')} + + + + + + + + +
codes = _duplicateCodes[idx].codes; + for (int i = 1; i < codes.length; i++) { + final display = codes[i].display; + final Code code = codes[i].copyWith( + display: display.copyWith(trashed: true), + ); + await CodeStore.instance.addCode(code); + } + } + } + Navigator.of(context).pop(); + } catch (e) { + _logger.severe('Failed to trash duplicate codes: ${e.toString()}'); + showGenericErrorDialog(context: context, error: e).ignore(); + } + }, + ); + } +} diff --git a/auth/lib/ui/settings/data/export_widget.dart b/auth/lib/ui/settings/data/export_widget.dart index 0df7482898..6d32424d3f 100644 --- a/auth/lib/ui/settings/data/export_widget.dart +++ b/auth/lib/ui/settings/data/export_widget.dart @@ -1,6 +1,5 @@ import 'dart:convert'; import 'dart:io'; - import 'package:ente_auth/core/configuration.dart'; import 'package:ente_auth/l10n/l10n.dart'; import 'package:ente_auth/models/export/ente.dart'; @@ -9,16 +8,15 @@ import 'package:ente_auth/store/code_store.dart'; import 'package:ente_auth/ui/components/buttons/button_widget.dart'; import 'package:ente_auth/ui/components/dialog_widget.dart'; import 'package:ente_auth/ui/components/models/button_type.dart'; +import 'package:ente_auth/ui/settings/data/html_export.dart'; import 'package:ente_auth/utils/dialog_util.dart'; import 'package:ente_auth/utils/platform_util.dart'; import 'package:ente_auth/utils/share_utils.dart'; import 'package:ente_auth/utils/toast_util.dart'; import 'package:ente_crypto_dart/ente_crypto_dart.dart'; import 'package:file_saver/file_saver.dart'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; -import 'package:logging/logging.dart'; import 'package:share_plus/share_plus.dart'; Future handleExportClick(BuildContext context) async { @@ -41,13 +39,22 @@ Future handleExportClick(BuildContext context) async { isInAlert: true, buttonAction: ButtonAction.second, ), + const ButtonWidget( + buttonType: ButtonType.secondary, + labelText: "HTML", + buttonSize: ButtonSize.large, + isInAlert: true, + buttonAction: ButtonAction.third, + ), ], ); if (result?.action != null && result!.action != ButtonAction.cancel) { if (result.action == ButtonAction.first) { await _requestForEncryptionPassword(context); - } else { - await _showExportWarningDialog(context); + } else if (result.action == ButtonAction.second) { + await _showExportWarningDialog(context, "txt"); + } else if (result.action == ButtonAction.third) { + await _showExportWarningDialog(context, "html"); } } } @@ -98,9 +105,8 @@ Future _requestForEncryptionPassword( ), ); // get json value of data - await _exportCodes(context, jsonEncode(data.toJson())); - } catch (e, s) { - Logger("ExportWidget").severe(e, s); + await _exportCodes(context, jsonEncode(data.toJson()), "txt"); + } catch (e) { showToast(context, "Error while exporting codes."); } } @@ -108,26 +114,34 @@ Future _requestForEncryptionPassword( ); } -Future _showExportWarningDialog(BuildContext context) async { +Future _showExportWarningDialog(BuildContext context, String type) async { await showChoiceActionSheet( context, title: context.l10n.warning, body: context.l10n.exportWarningDesc, isCritical: true, firstButtonOnTap: () async { - final data = await _getAuthDataForExport(); - await _exportCodes(context, data); + if (type == "html") { + final data = await generateHtml(context); + await _exportCodes(context, data, type); + } else { + final data = await _getAuthDataForExport(); + await _exportCodes(context, data, type); + } }, secondButtonLabel: context.l10n.cancel, firstButtonLabel: context.l10n.iUnderStand, ); } -Future _exportCodes(BuildContext context, String fileContent) async { +Future _exportCodes( + BuildContext context, + String fileContent, + String extension, +) async { DateTime now = DateTime.now().toUtc(); String formattedDate = DateFormat('yyyy-MM-dd').format(now); String exportFileName = 'ente-auth-codes-$formattedDate'; - String exportFileExtension = 'txt'; final hasAuthenticated = await LocalAuthenticationService.instance .requestLocalAuthentication(context, context.l10n.authToExportCodes); await PlatformUtil.refocusWindows(); @@ -142,14 +156,14 @@ Future _exportCodes(BuildContext context, String fileContent) async { saveAction: () async { await PlatformUtil.shareFile( exportFileName, - exportFileExtension, + extension, CryptoUtil.strToBin(fileContent), MimeType.text, ); }, sendAction: () async { final codeFile = File( - "${Configuration.instance.getTempDirectory()}$exportFileName.$exportFileExtension", + "${Configuration.instance.getTempDirectory()}$exportFileName.$extension", ); if (codeFile.existsSync()) { await codeFile.delete(); diff --git a/auth/lib/ui/settings/data/html_export.dart b/auth/lib/ui/settings/data/html_export.dart new file mode 100644 index 0000000000..705bd8507c --- /dev/null +++ b/auth/lib/ui/settings/data/html_export.dart @@ -0,0 +1,236 @@ +import 'dart:convert'; +import 'dart:ui' as ui; + +import 'package:ente_auth/models/code.dart'; +import 'package:ente_auth/store/code_store.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +Future generateQRImageBase64(String data) async { + final qrPainter = QrPainter( + data: data, + version: QrVersions.auto, + eyeStyle: const QrEyeStyle( + eyeShape: QrEyeShape.square, + color: Colors.black, + ), + dataModuleStyle: const QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.square, + color: Colors.black, + ), + ); + + const size = 250.0; + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + qrPainter.paint(canvas, const Size(size, size)); + final picture = recorder.endRecording(); + final img = await picture.toImage(size.toInt(), size.toInt()); + final byteData = await img.toByteData(format: ui.ImageByteFormat.png); + final pngBytes = byteData!.buffer.asUint8List(); + + return base64Encode(pngBytes); +} + +Future generateOTPEntryHtml( + Code code, + BuildContext context, +) async { + final qrBase64 = await generateQRImageBase64(code.rawData); + String notes = code.display.note; + if (notes.isNotEmpty) { + notes = 'Note: $notes'; + } + return ''' + + + ${code.issuer} + ${code.account} + + Type: ${code.type.name} + Algorithm: ${code.algorithm.name} + Digits: ${code.digits} + Recovery Code: ${code.secret} + $notes + + + + + + + '''; +} + +Future generateHtml(BuildContext context) async { + DateTime now = DateTime.now().toUtc(); + String formattedDate = DateFormat('d MMMM, yyyy').format(now); + final allCodes = await CodeStore.instance.getAllCodes(); + final List enteries = []; + + for (final code in allCodes) { + if (code.hasError) continue; + final entry = await generateOTPEntryHtml(code, context); + enteries.add(entry); + } + + return ''' + + + + + + + + Ente Auth + OTP Data Export + $formattedDate + + + + + ${enteries.join('')} + + + + + + + + +
Note: $notes
${code.issuer}
${code.account}
Type: ${code.type.name}
Algorithm: ${code.algorithm.name}
Digits: ${code.digits}
Recovery Code: ${code.secret}
$formattedDate
+ ${enteries.join('')} +