Compare commits

..

1 Commits

Author SHA1 Message Date
Hosted Weblate e9a81cdf36 chore(web): update translations
Co-authored-by: Aindriú Mac Giolla Eoin <aindriu80@gmail.com>
Co-authored-by: Enric Pagès i Gassull <enricpages@hotmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Hurricane_32 <rodrigorimo@hotmail.com>
Co-authored-by: Hồ Nhất Duy <axicenia@gmail.com>
Co-authored-by: Mahmoud Dwidar <modydodo2055@gmail.com>
Co-authored-by: bittin1ddc447d824349b2 <bittin@reimu.nl>
Co-authored-by: ikeno-web <ikeno@nextcore-consulting.com>
Co-authored-by: rubes <mail@armd.one>
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ar/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ca/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ga/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ja/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/pt/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/sq/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/sv/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/vi/
Translation: Immich/immich
2026-06-28 10:01:22 +00:00
29 changed files with 526 additions and 370 deletions
+1 -1
View File
@@ -1304,7 +1304,7 @@
"login_form_handshake_exception": "كان هناك استثناء مصافحة مع الخادم.تمكين دعم الشهادة الموقعة ذاتيا في الإعدادات إذا كنت تستخدم شهادة موقعة ذاتيا.",
"login_form_password_hint": "كلمة المرور",
"login_form_server_empty": "أدخل عنوان URL الخادم.",
"login_form_server_error": "لا يمكن الاتصال بالخادم.",
"login_form_server_error": "تعذر الاتصال بالخادم.",
"login_has_been_disabled": "تم تعطيل تسجيل الدخول.",
"login_password_changed_error": "كان هناك خطأ في تحديث كلمة المرور الخاصة بك",
"login_password_changed_success": "تم تحديث كلمة السر بنجاح",
+3
View File
@@ -1507,6 +1507,9 @@
"notes": "Notes",
"nothing_here_yet": "No hi ha res encara",
"notification_backup_reliability": "Activa les notificacions per millorar la fiabilitat de les còpies de seguretat en segon pla",
"notification_enabled_list_tile_content": "Immich utilitza les notificacions per a les còpies de seguretat en segon pla. Gestioneu-les a la configuració del vostre dispositiu.",
"notification_enabled_list_tile_open_button": "Obre la configuració",
"notification_enabled_list_tile_title": "Notificacions activades",
"notification_permission_dialog_content": "Per activar les notificacions, aneu a Configuració i seleccioneu permet.",
"notification_permission_list_tile_content": "Atorga permís per a activar les notificacions.",
"notification_permission_list_tile_enable_button": "Activa les notificacions",
+7 -3
View File
@@ -1303,9 +1303,9 @@
"login_form_failed_login": "Earráid ag logáil isteach, seiceáil URL an fhreastalaí, an ríomhphost agus an focal faire",
"login_form_handshake_exception": "Bhí Eisceacht Lámh-Chroith leis an bhfreastalaí. Cumasaigh tacaíocht do theastas féinshínithe sna socruithe má tá teastas féinshínithe in úsáid agat.",
"login_form_password_hint": "pasfhocal",
"login_form_server_empty": "Cuir isteach URL freastalaí.",
"login_form_server_error": "Níorbh fhéidir ceangal leis an bhfreastalaí.",
"login_has_been_disabled": "Tá logáil isteach díchumasaithe.",
"login_form_server_empty": "Cuir isteach URL freastalaí",
"login_form_server_error": "Níorbh fhéidir ceangal leis an bhfreastalaí",
"login_has_been_disabled": "Tá logáil isteach díchumasaithe",
"login_password_changed_error": "Tharla earráid agus do phasfhocal á nuashonrú",
"login_password_changed_success": "Nuashonraíodh an focal faire go rathúil",
"logout_all_device_confirmation": "An bhfuil tú cinnte gur mian leat logáil amach as gach gléas?",
@@ -1356,6 +1356,7 @@
"map_location_picker_page_use_location": "Úsáid an suíomh seo",
"map_location_service_disabled_content": "Ní mór seirbhís suímh a chumasú chun sócmhainní ó do shuíomh reatha a thaispeáint. Ar mhaith leat é a chumasú anois?",
"map_location_service_disabled_title": "Seirbhís Suímh díchumasaithe",
"map_marker_for_image": "Marcóir léarscáile don íomhá a tógadh i {city}, {country}",
"map_marker_with_image": "Marcóir léarscáile le híomhá",
"map_no_location_permission_content": "Tá cead suímh ag teastáil chun sócmhainní a thaispeáint ó do shuíomh reatha. Ar mhaith leat é a cheadú anois?",
"map_no_location_permission_title": "Cead Suímh diúltaithe",
@@ -1506,6 +1507,9 @@
"notes": "Nótaí",
"nothing_here_yet": "Níl aon rud anseo fós",
"notification_backup_reliability": "Cumasaigh fógraí chun iontaofacht cúltaca cúlra a fheabhsú",
"notification_enabled_list_tile_content": "Úsáideann Immich fógraí le haghaidh cúltaca cúlra. Bainistigh iad i socruithe do ghléis.",
"notification_enabled_list_tile_open_button": "Oscail socruithe",
"notification_enabled_list_tile_title": "Fógraí cumasaithe",
"notification_permission_dialog_content": "Chun fógraí a chumasú, téigh go Socruithe agus roghnaigh ceadaigh.",
"notification_permission_list_tile_content": "Tabhair cead fógraí a chumasú.",
"notification_permission_list_tile_enable_button": "Cumasaigh Fógraí",
+10
View File
@@ -193,6 +193,15 @@
"maintenance_integrity_checksum_mismatch_description": "ディスク上のチェックサムが、Immichのデータベースに保存されているチェックサムと一致しないファイル。",
"maintenance_integrity_checksum_mismatch_job": "チェックサム不一致を検出する",
"maintenance_integrity_checksum_mismatch_refresh_job": "チェックサム不一致レポートを再生成",
"maintenance_integrity_missing_file": "欠落ファイル",
"maintenance_integrity_missing_file_description": "Immichがデータベースで追跡しているが、ファイルシステム上に存在しないファイル。",
"maintenance_integrity_missing_file_job": "欠落ファイルのチェック",
"maintenance_integrity_missing_file_refresh_job": "欠落ファイルレポートの更新",
"maintenance_integrity_report": "整合性レポート",
"maintenance_integrity_untracked_file": "未追跡ファイル",
"maintenance_integrity_untracked_file_description": "Immichのディレクトリ内に存在するが、Immichに記録がないファイル。",
"maintenance_integrity_untracked_file_job": "未追跡ファイルのチェック",
"maintenance_integrity_untracked_file_refresh_job": "追跡されていないファイルレポートを更新",
"maintenance_restore_backup": "バックアップを復元",
"maintenance_restore_backup_description": "現在のImmichは削除され、選択したバックアップから復元されます。続行前にバックアップが作成されます。",
"maintenance_restore_backup_different_version": "このバックアップは異なるバージョンのImmichにより作成されたものです!",
@@ -546,6 +555,7 @@
"asset_added_to_album": "アルバムに追加",
"asset_adding_to_album": "アルバムに追加しています…",
"asset_created": "項目が作成されました",
"asset_day_count": "{date}: {count, plural, one {# アセット} other {# アセット}}",
"asset_description_updated": "項目の説明文が更新されました",
"asset_hashing": "ハッシュ計算中…",
"asset_list_group_by_sub_title": "グループ分け",
+7 -3
View File
@@ -1303,9 +1303,9 @@
"login_form_failed_login": "Ocorreu um erro ao iniciar sessão, verifique o URL do servidor, o e-mail e a palavra-passe",
"login_form_handshake_exception": "Erro ao conectar com o servidor. Ative o suporte para certificados auto-assinados nas configurações se estiver utilizando um certificado auto-assinado.",
"login_form_password_hint": "Palavra-passe",
"login_form_server_empty": "Insira a URL do servidor.",
"login_form_server_error": "Não foi possível ligar ao servidor.",
"login_has_been_disabled": "O início de sessão foi desativado.",
"login_form_server_empty": "Insira a URL do servidor",
"login_form_server_error": "Não foi possível ligar ao servidor",
"login_has_been_disabled": "O início de sessão foi desativado",
"login_password_changed_error": "Ocorreu um erro ao atualizar a sua palavra-passe",
"login_password_changed_success": "Palavra-passe atualizada com sucesso",
"logout_all_device_confirmation": "Tem a certeza de que deseja terminar a sessão em todos os dispositivos?",
@@ -1356,6 +1356,7 @@
"map_location_picker_page_use_location": "Utilizar esta localização",
"map_location_service_disabled_content": "Serviço de localização precisa de estar ativado para mostrar recursos da localização atual. Deseja ativar agora?",
"map_location_service_disabled_title": "Serviço de localização desativado",
"map_marker_for_image": "Marcador do mapa para a foto tirada em {city}, {country}",
"map_marker_with_image": "Marcador de mapa com imagem",
"map_no_location_permission_content": "A permissão da localização é necessária para mostrar recursos da localização atual. Deseja conceder a permissão agora?",
"map_no_location_permission_title": "Permissão de localização foi negada",
@@ -1506,6 +1507,9 @@
"notes": "Notas",
"nothing_here_yet": "Ainda não existe nada aqui",
"notification_backup_reliability": "Ativar notificações para melhorar a fiabilidade da cópia de segurança em segundo plano",
"notification_enabled_list_tile_content": "O Immich utiliza notificações para cópias de segurança em segundo plano. Faça a gestão destas nas definições do seu dispositivo.",
"notification_enabled_list_tile_open_button": "Abrir Definições",
"notification_enabled_list_tile_title": "Notificações ativadas",
"notification_permission_dialog_content": "Para ativar as notificações, vá em Configurações e selecione permitir.",
"notification_permission_list_tile_content": "Conceder permissões para ativar notificações.",
"notification_permission_list_tile_enable_button": "Ativar notificações",
+217 -1
View File
@@ -183,18 +183,23 @@
"machine_learning_smart_search_enabled": "Aktivizo kërkimin inteligjent",
"machine_learning_smart_search_enabled_description": "Nëse çaktivizohet, imazhet nuk do të kodohen për kërkim inteligjent.",
"machine_learning_url_description": "URL-ja e serverit të të mësuarit automatik. Nëse jepet më shumë se një URL, secili server do të provohet një nga një derisa njëri të përgjigjet me sukses, në rend nga i pari tek i fundit. Serverët që nuk përgjigjen do të injorohen përkohësisht derisa të kthehen në linjë.",
"maintenance_backup_management": "Menaxhimi i kopjeve rezervë",
"maintenance_delete_backup": "Fshi kopjen rezervë",
"maintenance_delete_backup_description": "Ky skedar do të fshihet në mënyrë të pakthyeshme.",
"maintenance_delete_error": "Fshirja e kopjes rezervë dështoi.",
"maintenance_integrity_check": "Kontrollo",
"maintenance_integrity_check_all": "Kontrollo të gjitha",
"maintenance_integrity_checksum_mismatch": "Mospërputhje e shumës së kontrollit",
"maintenance_integrity_checksum_mismatch_description": "Skedarët për të cilët checksumi në disk nuk përputhet me checksumin që Immich ka ruajtur në bazën e të dhënave të tij.",
"maintenance_integrity_checksum_mismatch_job": "Kontrolloni për mospërputhje të shumës së kontrollit",
"maintenance_integrity_checksum_mismatch_refresh_job": "Rifresko raportet e mospërputhjes së shumës së kontrollit",
"maintenance_integrity_missing_file": "Skedarët që mungojnë",
"maintenance_integrity_missing_file_description": "Skedarët që Immich i ka gjurmuar në bazën e të dhënave të tij, por që nuk ekzistojnë në sistemin e skedarëve.",
"maintenance_integrity_missing_file_job": "Kontrolloni për skedarë që mungojnë",
"maintenance_integrity_missing_file_refresh_job": "Rifresko raportet e skedarëve që mungojnë",
"maintenance_integrity_report": "Raporti i Integritetit",
"maintenance_integrity_untracked_file": "Skedarët e Pagjurmuar",
"maintenance_integrity_untracked_file_description": "Skedarët në direktoritë e Immich-it për të cilët Immich nuk ka asnjë regjistër.",
"maintenance_integrity_untracked_file_job": "Kontrolloni për skedarë të pagjurmuar",
"maintenance_integrity_untracked_file_refresh_job": "Rifresko raportet e skedarëve të pagjurmuar",
"maintenance_restore_backup": "Rivendos rezervën",
@@ -318,8 +323,219 @@
"reset_settings_to_default": "Rivendos cilësimet në ato të parazgjedhura",
"reset_settings_to_recent_saved": "Rivendos cilësimet në cilësimet e ruajtura së fundmi",
"scanning_library": "Duke skanuar bibliotekën",
"search_jobs": "Kërko punë…"
"search_jobs": "Kërko punë…",
"send_welcome_email": "Dërgo email mirëseardhjeje",
"server_external_domain_settings": "Domeni i jashtëm",
"server_external_domain_settings_description": "Domeni i përdorur për lidhjet e jashtme",
"server_public_users": "Përdoruesit publikë",
"server_public_users_description": "Të gjithë përdoruesit (emri dhe emaili) shfaqen kur shtohet një përdorues në albumet e përbashkëta. Kur është çaktivizuar, lista e përdoruesve do të jetë e disponueshme vetëm për përdoruesit admin.",
"server_settings": "Caktimet e serverit",
"server_settings_description": "Menaxho cilësimet e serverit",
"server_stats_page_description": "Faqja e statistikave të serverit administrativ",
"server_welcome_message": "Mesazh mirëseardhjeje",
"server_welcome_message_description": "Një mesazh që shfaqet në faqen e hyrjes.",
"settings_page_description": "Faqja e cilësimeve të administrimit",
"sidecar_job": "Metadata sidecar",
"sidecar_job_description": "Zbulo ose sinkronizo metadatat sidecar nga sistemi i skedarëve",
"slideshow_duration_description": "Numri i sekondave për me shfaqë çdo imazh",
"smart_search_job_description": "Ekzekuto mësim makine mbi asetet për me mbështetë kërkimin inteligjent",
"storage_template_date_time_description": "Markatimpi i krijimit të asetit përdoret për informacionin e datës dhe orës",
"storage_template_date_time_sample": "Koha e mostrës {date}",
"storage_template_enable_description": "Aktivizo motorin e shabllonit të ruajtjes",
"storage_template_hash_verification_enabled": "Verifikimi i hash-it asht aktivizuar",
"storage_template_hash_verification_enabled_description": "Aktivizon verifikimin e hash-it, mos e çaktivizo këtë nëse nuk je i sigurt për implikacionet",
"storage_template_migration": "Migrimi i shabllonit të ruajtjes",
"storage_template_migration_description": "Apliko <link>{template}</link> aktual te asetet e ngarkuara ma parë",
"storage_template_migration_info": "Shablloni i ruajtjes do t'i konvertojë të gjitha shtesat në shkronja të vogla. Ndryshimet e shabllonit do të aplikohen vetëm për asetet e reja. Për t'i aplikuar retroaktivisht te asetet e ngarkuara ma parë, ekzekuto <link>{job}</link>.",
"storage_template_migration_job": "Puna e migrimit të shabllonit të ruajtjes",
"storage_template_more_details": "Për ma shumë detaje rreth kësaj veçorie, referoju <template-link>Shabllonit të Ruajtjes</template-link> dhe <implications-link>implikacioneve</implications-link> të tij",
"storage_template_onboarding_description_v2": "Kur aktivizohet, kjo veçori do t'i organizojë automatikisht skedarët bazuar në një shabllon të definuar nga përdoruesi. Për ma shumë informacion, shih <link>dokumentacionin</link>.",
"storage_template_path_length": "Kufiri afërsisht i gjatësisë së shtegut: <b>{length, number}</b>/{limit, number}",
"storage_template_settings": "Shablloni i ruajtjes",
"storage_template_settings_description": "Menaxho strukturën e dosjeve dhe emrin e skedarit të asetit të ngarkuar",
"storage_template_user_label": "<code>{label}</code> asht Etiketa e Ruajtjes e përdoruesit",
"system_settings": "Cilësimet e sistemit",
"tag_cleanup_job": "Pastrimi i etiketave",
"template_email_available_tags": "Mund të përdorësh variablat e mëposhtme në shabllonin tënd: {tags}",
"template_email_if_empty": "Nëse shablloni asht bosh, do të përdoret emaili i parazgjedhur.",
"template_email_invite_album": "Shablloni i ftesës në album",
"template_email_preview": "Pamja paraprake",
"template_email_settings": "Shabllonet e emailit",
"template_email_update_album": "Shablloni i përditësimit të albumit",
"template_email_welcome": "Shablloni i emailit të mirëseardhjes",
"template_settings": "Shablonet e njoftimeve",
"template_settings_description": "Menaxho shablonet e personalizuara për njoftimet",
"theme_custom_css_settings": "CSS i personalizuar",
"theme_custom_css_settings_description": "Cascading Style Sheets lejojnë personalizimin e dizajnit të Immich.",
"theme_settings": "Cilësimet e temës",
"theme_settings_description": "Menaxho personalizimin e ndërfaqes web të Immich",
"thumbnail_generation_job": "Gjenero miniatura",
"thumbnail_generation_job_description": "Gjenero miniatura të mëdha, të vogla dhe të turbullta për çdo aset, si dhe miniatura për çdo person",
"transcoding_acceleration_api": "API-ja e përshpejtimit",
"transcoding_acceleration_api_description": "API-ja që do të ndërveprojë me pajisjen tënde për me përshpejtuar transkodimin. Ky cilësim asht 'best effort': do të kthehet te transkodimi softuerik nëse dështon. VP9 mund të funksionojë ose jo varësisht nga hardueri yt.",
"transcoding_acceleration_nvenc": "NVENC (kërkon GPU NVIDIA)",
"transcoding_acceleration_qsv": "Quick Sync (kërkon CPU Intel brez 7 ose ma të ri)",
"transcoding_acceleration_rkmpp": "RKMPP (vetëm në SOC-et Rockchip)",
"transcoding_acceleration_vaapi": "VAAPI",
"transcoding_accepted_audio_codecs": "Kodeckët e audios të pranuara",
"transcoding_accepted_audio_codecs_description": "Zgjidh cilët kodeckë audio nuk kanë nevojë të transkodihen. Përdoret vetëm për politika të caktuara transkodimi.",
"transcoding_accepted_containers": "Kontejnerët e pranuar",
"transcoding_accepted_containers_description": "Zgjidh cilët formate kontejneri nuk kanë nevojë të ripakohen në MP4. Përdoret vetëm për politika të caktuara transkodimi.",
"transcoding_accepted_video_codecs": "Kodeckët e videos të pranuara",
"transcoding_accepted_video_codecs_description": "Zgjidh cilët kodeckë video nuk kanë nevojë të transkodihen. Përdoret vetëm për politika të caktuara transkodimi.",
"transcoding_advanced_options_description": "Opsione që shumica e përdoruesve nuk kanë nevojë t'i ndryshojnë",
"transcoding_audio_codec": "Kodeku i audios",
"transcoding_audio_codec_description": "Opus asht opsioni me cilësi ma të lartë, por ka përputhshmëri ma të ulët me pajisje ose softuer të vjetër.",
"transcoding_bitrate_description": "Videot ma të larta se bitrate maksimal ose jo në format të pranuar",
"transcoding_codecs_learn_more": "Për me mësu ma shumë rreth terminologjisë këtu, referoju dokumentacionit FFmpeg për <h264-link>kodekun H.264</h264-link>, <hevc-link>kodekun HEVC</hevc-link> dhe <vp9-link>kodekun VP9</vp9-link>.",
"transcoding_constant_quality_mode": "Mënyra e cilësisë konstante",
"transcoding_constant_quality_mode_description": "ICQ asht ma e mirë se CQP, por disa pajisje të përshpejtimit harduerik nuk e mbështesin këtë mënyrë. Ky opsion do të preferojë mënyrën e specifikuar kur përdor kodim bazuar në cilësi. Injorohet nga NVENC pasi nuk mbështet ICQ.",
"transcoding_constant_rate_factor": "Faktori i normës konstante (-crf)",
"transcoding_constant_rate_factor_description": "Niveli i cilësisë së videos. Vlerat tipike janë 23 për H.264, 28 për HEVC, 31 për VP9 dhe 35 për AV1. Vlera ma e ulët asht ma e mirë, por prodhon skedarë ma të mëdhenj.",
"transcoding_disabled_description": "Mos transkodo asnjë video, mund të prish luajtjen në disa klientë",
"transcoding_encoding_options": "Opsionet e kodimit",
"transcoding_encoding_options_description": "Vendos kodeckë, rezolucion, cilësi dhe opsione të tjera për videot e koduara",
"transcoding_hardware_acceleration": "Përshpejtimi harduerik",
"transcoding_hardware_acceleration_description": "Eksperimental: transkodim ma i shpejtë por mund të ulë cilësinë me të njëjtin bitrate",
"transcoding_hardware_decoding": "Dekodimi harduerik",
"transcoding_hardware_decoding_setting_description": "Aktivizon përshpejtimin nga fillimi në fund në vend se të përshpejtojë vetëm kodimin. Mund të mos funksionojë me të gjitha videot.",
"transcoding_max_b_frames": "B-frames maksimale",
"transcoding_max_b_frames_description": "Vlerat ma të larta përmirësojnë efikasitetin e kompresimit, por ngadalësojnë kodimin. Mund të mos jetë i përputhshëm me përshpejtimin harduerik në pajisje të vjetra. 0 çaktivizonte B-frames, ndërsa -1 e vendos këtë vlerë automatikisht.",
"transcoding_max_bitrate": "Bitrate maksimal",
"transcoding_max_bitrate_description": "Vendosja e bitrate-it maksimal mund t'i bëjë madhësitë e skedarëve ma të parashikueshme me kosto të vogël në cilësi. Në 720p, vlerat tipike janë 2600 kbit/s për VP9 ose HEVC, ose 4500 kbit/s për H.264. Çaktivizohet nëse vendoset në 0. Kur nuk specifikohet njësia, supozohet k (kbit/s).",
"transcoding_max_keyframe_interval": "Intervali maksimal i keyframe",
"transcoding_max_keyframe_interval_description": "Vendos distancën maksimale të kornizave ndërmjet keyframe-ve. Vlerat ma të ulëta përkeqësojnë efikasitetin e kompresimit, por përmirësojnë kohët e kërkimit dhe mund të përmirësojnë cilësinë në skenat me lëvizje të shpejtë. 0 e vendos këtë vlerë automatikisht.",
"transcoding_optimal_description": "Videot ma të larta se rezolucioni i synuar ose jo në format të pranuar",
"transcoding_policy": "Politika e transkodimit",
"transcoding_policy_description": "Vendos kur do të transkodihet një video",
"transcoding_preferred_hardware_device": "Pajisja harduerike e preferuar",
"transcoding_preferred_hardware_device_description": "Aplikohet vetëm për VAAPI dhe QSV. Vendos nyjen dri të përdorur për transkodim harduerik.",
"transcoding_preset_preset": "Paravendosja (-preset)",
"transcoding_preset_preset_description": "Shpejtësia e kompresimit. Paravendosjet ma të ngadalta prodhojnë skedarë ma të vegjël dhe rrisin cilësinë kur synon një bitrate të caktuar. VP9 injoron shpejtësi mbi 'faster'.",
"transcoding_realtime": "Transkodimi në kohë reale [EKSPERIMENTAL]",
"transcoding_realtime_description": "Lejon transkodimin të kryhet në kohë reale ndërsa videoja transmetohet. Aktivizon ndërrimin e cilësisë, por mund të shkaktojë vonesë ma të lartë luajtjeje dhe ngecje varësisht nga kapacitetet e serverit.",
"transcoding_realtime_enabled": "Aktivizo transkodimin në kohë reale",
"transcoding_realtime_enabled_description": "Nëse çaktivizohet, serveri do të refuzojë me fillu sesione të reja transkodimi në kohë reale.",
"transcoding_reference_frames": "Kornizat referencë",
"transcoding_reference_frames_description": "Numri i kornizave për t'u referuar kur kompresohet një kornizë e dhënë. Vlerat ma të larta përmirësojnë efikasitetin e kompresimit, por ngadalësojnë kodimin. 0 e vendos këtë vlerë automatikisht.",
"transcoding_required_description": "Vetëm videot jo në format të pranuar",
"transcoding_settings": "Cilësimet e transkodimit të videos",
"transcoding_settings_description": "Menaxho cilat video të transkodihen dhe si t'i procesosh ato",
"transcoding_target_resolution": "Rezolucioni i synuar",
"transcoding_target_resolution_description": "Rezolucionet ma të larta mund të ruajnë ma shumë detaje por marrin ma shumë kohë për t'u koduar, kanë madhësi skedarësh ma të mëdha dhe mund të reduktojnë reagimin e aplikacionit.",
"transcoding_temporal_aq": "Temporal AQ",
"transcoding_temporal_aq_description": "Aplikohet vetëm për NVENC. Kuantizimi adaptiv temporal rrit cilësinë e skenave me shumë detaje dhe lëvizje të ulët. Mund të mos jetë i përputhshëm me pajisje të vjetra.",
"transcoding_threads": "Threads",
"transcoding_threads_description": "Vlerat ma të larta çojnë në kodim ma të shpejtë, por lënë ma pak hapësirë për serverin me procesuar detyra të tjera ndërsa asht aktiv. Kjo vlerë nuk duhet të jetë ma e madhe se numri i bërthamave CPU. Maksimizon përdorimin nëse vendoset në 0.",
"transcoding_tone_mapping": "Tone-mapping",
"transcoding_tone_mapping_description": "Përpiqet të ruajë pamjen e videove HDR kur konvertohen në SDR. Çdo algoritëm bën kompromise të ndryshme për ngjyrën, detajet dhe ndriçimin. Hable ruan detajet, Mobius ruan ngjyrën dhe Reinhard ruan ndriçimin.",
"transcoding_transcode_policy": "Politika e transkodimit",
"transcoding_transcode_policy_description": "Politika për kur duhet të transkodihet një video. Videot HDR dhe videot me format pikselësh tjetër se YUV 4:2:0 do të transkodihen gjithmonë (përveç nëse transkodimi asht çaktivizuar).",
"transcoding_two_pass_encoding": "Kodim me dy kalime",
"transcoding_two_pass_encoding_setting_description": "Transkodo në dy kalime për me prodhuar video ma mirë të koduara. Kur bitrate maksimal asht aktivizuar (i nevojshëm për të punuar me H.264 dhe HEVC), kjo mënyrë përdor një gamë bitrate bazuar në bitrate maksimal dhe injoron CRF. Për VP9, CRF mund të përdoret nëse bitrate maksimal asht çaktivizuar.",
"transcoding_video_codec": "Kodeku i videos",
"transcoding_video_codec_description": "VP9 ka efikasitet të lartë dhe përputhshmëri web, por merr ma shumë kohë për t'u transkoduar. HEVC funksionon ngjashëm, por ka përputhshmëri ma të ulët web. H.264 asht i përputhshëm gjerësisht dhe i shpejtë për t'u transkoduar, por prodhon skedarë shumë ma të mëdhenj. AV1 asht kodeku ma efikas por mungon mbështetja në pajisje të vjetra.",
"trash_enabled_description": "Aktivizo veçoritë e koshit",
"trash_number_of_days": "Numri i ditëve",
"trash_number_of_days_description": "Numri i ditëve për të mbajtur asetet në kosh para fshirjes përgjithmonë",
"trash_settings": "Cilësimet e koshit",
"trash_settings_description": "Menaxho cilësimet e koshit",
"unlink_all_oauth_accounts": "Shkyç të gjitha llogaritë OAuth",
"unlink_all_oauth_accounts_description": "Mos harro me shkyçë të gjitha llogaritë OAuth para migrimit te një ofrues i ri.",
"unlink_all_oauth_accounts_prompt": "A je i sigurt që dëshiron të shkyçësh të gjitha llogaritë OAuth? Kjo do të rivendosë OAuth ID-në për çdo përdorues dhe nuk mund të zhbëhet.",
"user_cleanup_job": "Pastrimi i përdoruesve",
"user_delete_delay": "Llogaria dhe asetet e <b>{user}</b> do të planifikohen për fshirje të përhershme pas {delay, plural, one {# ditë} other {# ditësh}}.",
"user_delete_delay_settings": "Vonesa e fshirjes",
"user_delete_delay_settings_description": "Numri i ditëve pas heqjes për me fshirë përgjithmonë llogarinë dhe asetet e një përdoruesi. Puna e fshirjes së përdoruesit ekzekutohet në mesnatë për të kontrolluar përdoruesit gati për fshirje. Ndryshimet e këtij cilësimi do të vlerësohen në ekzekutimin tjetër.",
"user_delete_immediately": "Llogaria dhe asetet e <b>{user}</b> do të radhiten për fshirje të përhershme <b>menjëherë</b>.",
"user_delete_immediately_checkbox": "Radho përdoruesin dhe asetet për fshirje të menjëhershme",
"user_details": "Detajet e përdoruesit",
"user_management": "Menaxhimi i përdoruesve",
"user_password_has_been_reset": "Fjalëkalimi i përdoruesit asht rivendosur:",
"user_password_reset_description": "Ju lutem jepja përdoruesit fjalëkalimin e përkohshëm dhe informoje se do të duhet ta ndryshojë fjalëkalimin në hyrjen e tij të ardhshme.",
"user_restore_description": "Llogaria e <b>{user}</b> do të restaurohet.",
"user_restore_scheduled_removal": "Restauro përdoruesin - heqja e planifikuar më {date, date, long}",
"user_settings": "Cilësimet e përdoruesit",
"user_settings_description": "Menaxho cilësimet e përdoruesit",
"user_successfully_removed": "Përdoruesi {email} u hoq me sukses.",
"users_page_description": "Faqja e administratorit për përdoruesit",
"version_check_channel": "Kanali i lëshimit",
"version_check_channel_description": "Zgjidh kanalin e lëshimit për të cilin dëshiron të marrësh njoftime versioni",
"version_check_enabled_description": "Aktivizo kontrollin e versionit",
"version_check_implications": "Veçoria e kontrollit të versionit mbështetet në komunikim periodik me {server}",
"version_check_settings": "Kontrolli i versionit",
"version_check_settings_description": "Aktivizo/çaktivizo njoftimin e versionit të ri",
"video_conversion_job": "Transkodo videot",
"video_conversion_job_description": "Transkodo videot për përputhshmëri ma të gjerë me shfletuesit dhe pajisjet"
},
"admin_email": "Email i administratorit",
"admin_password": "Fjalëkalimi i administratorit",
"administration": "Administrimi",
"advanced": "Të avancuara",
"advanced_settings_clear_image_cache": "Pastro cache-in e imazheve",
"advanced_settings_clear_image_cache_error": "Dështoi pastrimi i cache-it të imazheve",
"advanced_settings_clear_image_cache_success": "U pastrua me sukses {size}",
"advanced_settings_log_level_title": "Niveli i regjistrit: {level}",
"advanced_settings_prefer_remote_subtitle": "Disa pajisje janë shumë të ngadalta për të ngarkuar miniatura nga asetet lokale. Aktivizo këtë cilësim për të ngarkuar imazhe nga distanca.",
"advanced_settings_prefer_remote_title": "Prefero imazhe nga distanca",
"advanced_settings_proxy_headers_subtitle": "Defino header-at proxy që Immich duhet t'i dërgojë me çdo kërkesë rrjeti",
"advanced_settings_proxy_headers_title": "Header-at e personalizuara proxy [EKSPERIMENTAL]",
"advanced_settings_readonly_mode_subtitle": "Aktivizon mënyrën vetëm-lexim ku fotot mund të shikohen vetëm, gjëra si zgjedhja e imazheve të shumta, ndarja, transmetimi, fshirja janë të çaktivizuara. Aktivizo/çaktivizo vetëm-lexim nëpërmjet avatarit të përdoruesit nga ekrani kryesor",
"advanced_settings_readonly_mode_title": "Mënyra vetëm-lexim",
"advanced_settings_sync_remote_deletions_subtitle": "Fshi ose restauro automatikisht një aset në këtë pajisje kur kjo veprim kryhet në web",
"advanced_settings_sync_remote_deletions_title": "Sinkronizo fshirjet nga distanca [EKSPERIMENTAL]",
"advanced_settings_tile_subtitle": "Cilësimet e avancuara të përdoruesit",
"advanced_settings_troubleshooting_subtitle": "Aktivizo veçori shtesë për zgjidhjen e problemeve",
"advanced_settings_troubleshooting_title": "Zgjidhja e problemeve",
"age_months": "Mosha {months, plural, one {# muaj} other {# muaj}}",
"age_year_months": "Mosha 1 vit, {months, plural, one {# muaj} other {# muaj}}",
"age_years": "{years, plural, other {Mosha #}}",
"album": "Album",
"album_added": "Albumi u shtua",
"album_added_notification_setting_description": "Merr njoftim me email kur shtohet në një album të ndarë",
"album_cover_updated": "Kopertina e albumit u përditësua",
"album_delete_confirmation": "A je i sigurt që dëshiron të fshish albumin {album}?",
"album_delete_confirmation_description": "Nëse ky album asht i ndarë, përdoruesit e tjerë nuk do të mund ta aksesojnë ma.",
"album_deleted": "Albumi u fshi",
"album_info_updated": "Informacioni i albumit u përditësua",
"album_name": "Emri i albumit",
"album_options": "Opsionet e albumit",
"album_remove_user": "Hiq përdoruesin?",
"album_remove_user_confirmation": "A je i sigurt që dëshiron të heqësh {user}?",
"album_search_not_found": "Nuk u gjet asnjë album që përputhet me kërkimin tënd",
"album_share_no_users": "Duket se e ke ndarë këtë album me të gjithë përdoruesit ose nuk ke asnjë përdorues me të cilin të ndash.",
"album_summary": "Përmbledhja e albumit",
"album_updated": "Albumi u përditësua",
"album_updated_setting_description": "Merr njoftim me email kur një album i ndarë ka asete të reja",
"album_upload_assets": "Ngarko asete nga kompjuteri yt dhe shto në album",
"album_viewer_appbar_share_err_delete": "Dështoi fshirja e albumit",
"album_viewer_page_share_add_users": "Shto përdorues",
"album_with_link_access": "Lejo këdo me lidhjen të shohë fotot dhe njerëzit në këtë album.",
"albums": "Albumet",
"albums_count": "{count, plural, one {{count, number} Album} other {{count, number} Albume}}",
"albums_default_sort_order": "Rendi i parazgjedhur i albumeve",
"albums_default_sort_order_description": "Rendi fillestar i aseteve kur krijohen albume të reja.",
"albums_feature_description": "Koleksione asetesh që mund të ndahen me përdoruesit e tjerë.",
"albums_on_device_count": "Albumet në pajisje ({count})",
"all": "Të gjithë",
"all_albums": "Të gjitha albumet",
"all_people": "Të gjithë njerëzit",
"all_photos": "Të gjitha fotot",
"all_videos": "Të gjitha videot",
"allow_dark_mode": "Lejo mënyrën e errët",
"allow_public_user_to_download": "Lejo përdoruesin publik të shkarkojë",
"allow_public_user_to_upload": "Lejo përdoruesin publik të ngarkojë",
"allowed": "I lejuar",
"alt_text_qr_code": "Imazh i kodit QR",
"always_keep": "Mbaj gjithmonë",
"always_keep_photos_hint": "Lirimi i hapësirës do t'i mbajë të gjitha fotot në këtë pajisje.",
"always_keep_videos_hint": "Lirimi i hapësirës do t'i mbajë të gjitha videot në këtë pajisje.",
"api_key": "Çelësi API",
"api_key_description": "Kjo vlerë do të tregohet vetëm një herë. Sigurohu me e kopjuar para se të mbyllësh dritaren.",
"api_key_empty": "Emri i çelësit API nuk duhet të jetë bosh",
"api_keys": "Çelësat API",
"app_architecture_variant": "Varianta (Arkitektura)",
"app_bar_signout_dialog_content": "A je i sigurt që dëshiron të dalësh?",
"download_original": "Shkarko origjinalin",
"download_paused": "Shkarkimi u pezullua",
"download_settings": "Shkarko",
+6 -3
View File
@@ -1303,9 +1303,9 @@
"login_form_failed_login": "Kunde inte logga in. Kontrollera serverns webbadress, email och lösenord",
"login_form_handshake_exception": "Ett Undantag vid Handskakning med servern har skett. Aktivera stöd för självsignerade certifikat i inställningar om du använder ett självsignerat certifikat.",
"login_form_password_hint": "lösenord",
"login_form_server_empty": "Ange en server-URL.",
"login_form_server_error": "Kunde inte ansluta till servern.",
"login_has_been_disabled": "Inloggning har blivit inaktiverat.",
"login_form_server_empty": "Ange en server-URL",
"login_form_server_error": "Kunde inte ansluta till servern",
"login_has_been_disabled": "Inloggning har blivit inaktiverat",
"login_password_changed_error": "Ett fel uppstod vid uppdatering av ditt lösenord",
"login_password_changed_success": "Uppdatering av lösenord lyckades",
"logout_all_device_confirmation": "Är du säker på att du vill logga ut från alla enheter?",
@@ -1507,6 +1507,9 @@
"notes": "Notera",
"nothing_here_yet": "Inget här ännu",
"notification_backup_reliability": "Aktivera aviseringar för att förbättra säkerhetskopieringen i bakgrunden",
"notification_enabled_list_tile_content": "Immich använde notiser för bakgrundssäkerhetskopiering. Hantera dem på din enhets inställningar.",
"notification_enabled_list_tile_open_button": "Öppna inställningar",
"notification_enabled_list_tile_title": "Notiser aktiverade",
"notification_permission_dialog_content": "För att aktivera notiser, gå till Inställningar och välj tillåt.",
"notification_permission_list_tile_content": "Tillåt rättighet för att slå på notiser.",
"notification_permission_list_tile_enable_button": "Aktivera Notiser",
+23 -23
View File
@@ -476,16 +476,16 @@
"advanced_settings_clear_image_cache_error": "Lỗi khi xóa bộ nhớ đệm",
"advanced_settings_clear_image_cache_success": "Đã giải phóng thành công {size}",
"advanced_settings_log_level_title": "Phân loại log: {level}",
"advanced_settings_prefer_remote_subtitle": "Việc tải ảnh thu nhỏ từ tài nguyên trên một số thiết bị có thể diễn ra chậm. Kích hoạt cài đặt này để tải ảnh từ máy chủ.",
"advanced_settings_prefer_remote_subtitle": "Một số thiết bị nạp ảnh thu nhỏ từ tài nguyên thiết bị rất chậm. Bật để nạp ảnh từ máy chủ.",
"advanced_settings_prefer_remote_title": "Ưu tiên ảnh từ máy chủ",
"advanced_settings_proxy_headers_subtitle": "Xác định các tiêu đề proxy Immich sẽ gửi kèm mỗi yêu cầu mạng",
"advanced_settings_proxy_headers_title": "Tùy chỉnh tiêu đề proxy [THỬ NGHIỆM]",
"advanced_settings_readonly_mode_subtitle": "Chế độ chỉ-xem chỉ cho phép xem ảnh, các tính năng như chọn nhiều ảnh, chia sẻ, phát, xóa đều bị vô hiệu hóa. Bật/Tắt chế độ chỉ-xem thông qua ảnh đại diện người dùng từ màn hình chính",
"advanced_settings_readonly_mode_subtitle": "Chỉ dùng ứng dụng để xem ảnh - các tính năng khác như chọn nhiều mục, chia sẻ, phát, xóa đều bị vô hiệu hóa. Nhấn giữ ảnh đại diện người dùng từ màn hình chính để bật/tắt chế độ.",
"advanced_settings_readonly_mode_title": "Chế độ chỉ-xem",
"advanced_settings_sync_remote_deletions_subtitle": "Tự động xóa hoặc khôi phục tài nguyên trên thiết bị này khi bạn thao tác trên web",
"advanced_settings_sync_remote_deletions_title": "Đồng bộ việc xóa từ thiết bị khác [THỬ NGHIỆM]",
"advanced_settings_tile_subtitle": "Dành cho người dùng nâng cao",
"advanced_settings_troubleshooting_subtitle": "Bật các tính năng bổ sung để xử lý sự cố",
"advanced_settings_troubleshooting_subtitle": "Bật tính năng bổ sung để xử lý sự cố",
"advanced_settings_troubleshooting_title": "Xử lý sự cố",
"age_months": "{months, plural, one {# tháng} other {# tháng}} tuổi",
"age_year_months": "1 tuổi, {months, plural, one {# tháng} other {# tháng}}",
@@ -630,7 +630,7 @@
"backup_info_card_assets": "tài nguyên",
"backup_options": "Tùy chọn sao lưu",
"backup_settings_subtitle": "Cài đặt việc tải lên",
"backward": "Lùi lại",
"backward": "Lùi",
"battery_optimization_backup_reliability": "Tắt tính năng tiết kiệm pin để đảm bảo quá trình sao lưu nền",
"biometric_auth_enabled": "Đã bật xác thực sinh trắc học",
"biometric_locked_out": "Bạn đã bị khóa xác thực bằng sinh trắc học",
@@ -684,11 +684,11 @@
"choose": "Chọn",
"choose_matching_people_to_merge": "Chọn những người trùng khớp để hợp nhất",
"city": "Thành phố",
"cleanup_confirm_description": "Immich phát hiện {count} tài nguyên (được tạo ra trước {date}) được sao lưu trên máy chủ. Bạn có muốn xóa bản sao được lưu trên thiết bị này không?",
"cleanup_confirm_description": "Immich tìm thấy {count} tài nguyên (được tạo ra trước {date}) được sao lưu trên máy chủ. Bạn có muốn xóa phiên bản của chúng lưu trên thiết bị này không?",
"cleanup_confirm_prompt_title": "Xóa khỏi thiết bị này?",
"cleanup_deleted_assets": "Đã chuyển {count} tài nguyên vào thùng rác",
"cleanup_deleting": "Đang chuyển vào thùng rác...",
"cleanup_found_assets": "Phát hiện {count} tài nguyên được sao lưu",
"cleanup_found_assets": "Tìm thấy {count} tài nguyên đã sao lưu",
"cleanup_found_assets_with_size": "Phát hiện {count} tài nguyên đã sao lưu ({size})",
"cleanup_icloud_shared_albums_excluded": "Những album được chia sẻ trên iCloud không nằm trong phạm vi quét",
"cleanup_no_assets_found": "Không tìm thấy tài nguyên nào phù hợp với điều kiện trên. Tính năng Giải phóng dung lượng chỉ có thể xóa các tài nguyên đã được sao lưu lên máy chủ",
@@ -791,11 +791,11 @@
"current_device": "Thiết bị hiện tại",
"current_pin_code": "Mã PIN hiện tại",
"current_server_address": "Địa chủ máy chủ hiện tại",
"custom_date": "Thiết lập ngày tùy chỉnh",
"custom_date": "Tùy chỉnh mốc",
"custom_locale": "Khu vực tùy chỉnh",
"custom_locale_description": "Định dạng ngày, thời gian và số dựa trên ngôn ngữ và khu vực đã chọn",
"custom_url": "URL tùy chỉnh",
"cutoff_date_description": "Giữ lại ảnh trong vòng…",
"cutoff_date_description": "Giữ lại các ảnh t…",
"cutoff_day": "{count, plural, one {ngày} other {ngày}}",
"cutoff_year": "{count, plural, one {năm} other {năm}}",
"dark": "Tối",
@@ -842,7 +842,7 @@
"description": "Mô tả",
"deselect_all": "Bỏ chọn tất cả",
"details": "Chi tiết",
"direction": "Hướng",
"direction": "Điều hướng",
"disable": "Vô hiệu hóa",
"disabled": "Đã tắt",
"discord": "Discord",
@@ -1123,10 +1123,10 @@
"folders": "Thư mục",
"folders_feature_description": "Duyệt ảnh và video theo thư mục trên hệ thống tệp",
"forgot_pin_code_question": "Quên mã PIN?",
"forward": "Tiến tới",
"forward": "Tiến",
"free_up_space": "Giải phóng dung lượng",
"free_up_space_description": "Chuyển hình ảnh và video đã được sao lưu vào thùng rác của thiết bị để giải phóng dung lượng. Bản sao lưu trên máy chủ không bị ảnh hưởng.",
"free_up_space_settings_subtitle": "Tiết kiệm dung lượng bộ nhớ thiết bị",
"free_up_space_description": "Chuyển các hình ảnh và video đã được sao lưu vào thùng rác thiết bị. Bản sao lưu trên máy chủ không bị ảnh hưởng.",
"free_up_space_settings_subtitle": "Tiết kiệm bộ nhớ thiết bị",
"full_path": "Đường dẫn đầy đủ: {path}",
"full_path_or_folder": "Đường dẫn đầy đủ hoặc thư mục",
"gcast_enabled": "Google Cast",
@@ -1215,13 +1215,13 @@
"items_count": "{count, plural, one {# mục} other {# mục}}",
"jobs": "Tác vụ",
"keep": "Giữ",
"keep_albums": "Giữ lại các tập ảnh",
"keep_albums": "Giữ lại các album",
"keep_albums_count": "Giữ lại {count} {count, plural, one {album} other {album}}",
"keep_all": "Giữ tất cả",
"keep_description": "Chọn giữ lại những gì trên thiết bị khi giải phóng dung lượng.",
"keep_favorites": "Giữ lại các mục yêu thích",
"keep_on_device": "Giữ lại trên thiết bị",
"keep_on_device_hint": "Chọn các tệp sẽ được giữ lại trên thiết bị",
"keep_on_device_hint": "Chọn các mục sẽ không xóa",
"keep_this_delete_others": "Giữ tệp này, xóa các tệp khác",
"keeping": "Giữ lại: {items}",
"kept_this_deleted_others": "Đã giữ lại tài nguyên này và xóa {count, plural, one {# tài nguyên} other {# tài nguyên}}",
@@ -1312,7 +1312,7 @@
"logout_this_device_confirmation": "Bạn có chắc muốn đăng xuất thiết bị này?",
"logs": "Log",
"longitude": "Kinh độ",
"look": "Xem",
"look": "Hiển thị",
"loop_videos": "Lặp video",
"loop_videos_description": "Bật để video tự động lặp lại trong trình xem chi tiết.",
"main_branch_warning": "Bạn đang dùng phiên bản đang phát triển; chúng tôi khuyên bạn nên dùng phiên bản phát hành!",
@@ -1344,7 +1344,7 @@
"manage_media_access_rationale": "Để có thể di chuyển tài nguyên vào thùng rác và khôi phục chúng từ đó.",
"manage_media_access_settings": "Mở cài đặt",
"manage_media_access_subtitle": "Cho phép ứng dụng [Immich] quản lý và di chuyển tệp.",
"manage_media_access_title": "Quản lý phương tiện",
"manage_media_access_title": "Quản lý media",
"manage_sharing_with_partners": "Quản lý chia sẻ với người thân",
"manage_the_app_settings": "Quản lý cài đặt ứng dụng",
"manage_your_account": "Quản lý tài khoản của bạn",
@@ -1696,8 +1696,8 @@
"reassing_hint": "Gán các tài nguyên đã chọn cho một người hiện có",
"recent": "Gần đây",
"recent_searches": "Tìm kiếm gần đây",
"recently_added": ược thêm gần đây",
"recently_added_page_title": ược thêm gần đây",
"recently_added": ã thêm gần đây",
"recently_added_page_title": ã thêm gần đây",
"recently_taken": "Chụp gần đây",
"refresh": "Làm mới",
"refresh_encoded_videos": "Làm mới video đã mã hóa",
@@ -1842,7 +1842,7 @@
"select_all_duplicates": "Chọn tất cả các bản trùng lặp",
"select_avatar_color": "Chọn màu ảnh đại diện",
"select_count": "{count, plural, one {Chọn #} other {Chọn #}}",
"select_cutoff_date": "Chọn giới hạn thời gian",
"select_cutoff_date": "Chọn mốc thời gian",
"select_face": "Chọn khuôn mặt",
"select_featured_photo": "Chọn ảnh nổi bật",
"select_from_computer": "Chọn từ máy tính",
@@ -1901,7 +1901,7 @@
"setup_pin_code": "Thiết lập mã PIN",
"share": "Chia sẻ",
"share_dialog_preparing": "Đang xử lý...",
"share_link": "Link chia sẻ",
"share_link": "Chia sẻ link",
"share_original": "Chất lượng gốc (lớn)",
"share_preview": "Dùng ảnh thu nhỏ (nhỏ)",
"shared": "Đã chia sẻ",
@@ -1915,7 +1915,7 @@
"shared_by_you": "Được chia sẻ bởi bạn",
"shared_from_partner": "Ảnh từ {partner}",
"shared_intent_upload_button_progress_text": "{current} / {total} Đã tải lên",
"shared_link_app_bar_title": "Liên kết đã chia sẻ",
"shared_link_app_bar_title": "Những link đã chia sẻ",
"shared_link_clipboard_copied_massage": "Đã sao chép vào clipboard",
"shared_link_create_error": "Tạo liên kết chia sẻ không thành công",
"shared_link_custom_url_description": "Truy cập link đã chia sẻ với một URL tùy chỉnh",
@@ -1945,7 +1945,7 @@
"shared_link_manage_links": "Quản lý các link đã chia sẻ",
"shared_link_options": "Tùy chọn chia sẻ link",
"shared_link_password_description": "Bắt buộc để truy cập link chia sẻ này",
"shared_links": "Chia sẻ link",
"shared_links": "Đã chia sẻ",
"shared_links_description": "Chia sẻ ảnh và video bằng link",
"shared_photos_and_videos_count": "{assetCount, plural, other {# ảnh & video đã chia sẻ.}}",
"shared_with_me": "Chia sẻ với tôi",
@@ -1992,7 +1992,7 @@
"slideshow_metadata_overlay_mode": "Lớp phủ nội dung",
"slideshow_metadata_overlay_mode_description_only": "Chỉ mô tả",
"slideshow_metadata_overlay_mode_full": "Đầy đủ",
"slideshow_repeat": "Trình chiếu lại",
"slideshow_repeat": "Lặp lại trình chiếu",
"slideshow_repeat_description": "Phát lại từ đầu khi trình chiếu kết thúc",
"slideshow_settings": "Cài đặt trình chiếu",
"smart_album": "Album thông minh",
@@ -77,22 +77,4 @@ class AssetService {
await _apiRepository.updateFavorite(remoteIds, isFavorite);
await _remoteRepository.updateFavorite(remoteIds, isFavorite);
}
Future<void> stack(String userId, List<String> remoteIds) async {
if (remoteIds.isEmpty) {
return;
}
final stack = await _apiRepository.stack(remoteIds);
await _remoteRepository.stack(userId, stack);
}
Future<void> unStack(List<String> stackIds) async {
if (stackIds.isEmpty) {
return;
}
await _remoteRepository.unStack(stackIds);
await _apiRepository.unStack(stackIds);
}
}
@@ -17,9 +17,11 @@ class FavoriteAction extends AssetAction<RemoteAsset> {
String label(ActionScope scope) => shouldFavorite ? scope.context.t.favorite : scope.context.t.unfavorite;
@override
Iterable<RemoteAsset> filter(ActionScope scope) => assets.whereType<RemoteAsset>().where(
(asset) => asset.ownerId == scope.authUser.id && asset.isFavorite == !shouldFavorite,
);
Iterable<RemoteAsset> filter(ActionScope scope) => assets
.where(
(asset) => asset is RemoteAsset && asset.ownerId == scope.authUser.id && asset.isFavorite == !shouldFavorite,
)
.cast<RemoteAsset>();
@override
bool isVisible(ActionScope scope) => filter(scope).isNotEmpty;
@@ -1,44 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_ui/immich_ui.dart';
class StackAction extends AssetAction<RemoteAsset> {
final bool shouldStack;
StackAction({required super.assets})
: shouldStack = assets.any((asset) => asset is RemoteAsset && asset.stackId == null);
@override
IconData get icon => shouldStack ? Icons.filter_none_rounded : Icons.layers_clear_outlined;
@override
String label(ActionScope scope) => shouldStack ? scope.context.t.stack : scope.context.t.unstack;
@override
Iterable<RemoteAsset> filter(ActionScope scope) =>
assets.whereType<RemoteAsset>().where((asset) => asset.ownerId == scope.authUser.id);
@override
bool isVisible(ActionScope scope) => shouldStack ? filter(scope).length > 1 : filter(scope).isNotEmpty;
@override
Future<void> onAction(ActionScope scope) async {
final ActionScope(:ref) = scope;
final assets = filter(scope).toList(growable: false);
final service = ref.read(assetServiceProvider);
if (shouldStack) {
await service.stack(scope.authUser.id, assets.map((asset) => asset.id).toList(growable: false));
} else {
await service.unStack(assets.map((asset) => asset.stackId).nonNulls.toList(growable: false));
}
final message = shouldStack
? StaticTranslations.instance.stacked_assets_count(count: assets.length)
: StaticTranslations.instance.unstacked_assets_count(count: assets.length);
snackbar.success(message);
}
}
@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class StackActionButton extends ConsumerWidget {
final ActionSource source;
const StackActionButton({super.key, required this.source});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final user = ref.watch(currentUserProvider);
if (user == null) {
throw Exception('User must be logged in to access stack action');
}
final result = await ref.read(actionProvider.notifier).stack(user.id, source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'stack_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.filter_none_rounded,
label: "stack".t(context: context),
onPressed: () => _onTap(context, ref),
);
}
}
@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class UnStackActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const UnStackActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).unStack(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'unstack_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.layers_clear_outlined,
label: "unstack".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}
@@ -5,7 +5,6 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
@@ -15,8 +14,10 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
@@ -76,7 +77,7 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [FavoriteAction(assets: assets), StackAction(assets: assets)];
final actions = [FavoriteAction(assets: assets)];
return BaseBottomSheet(
controller: sheetController,
@@ -96,6 +97,8 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
],
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
],
@@ -6,7 +6,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
@@ -17,7 +16,9 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
@@ -67,7 +68,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [FavoriteAction(assets: assets), StackAction(assets: assets)];
final actions = [FavoriteAction(assets: assets)];
return BaseBottomSheet(
initialChildSize: 0.4,
@@ -86,6 +87,8 @@ class FavoriteBottomSheet extends ConsumerWidget {
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
],
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
],
@@ -5,7 +5,6 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart';
@@ -19,7 +18,9 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_actio
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
@@ -83,7 +84,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [AssetDebugAction(assets: assets), StackAction(assets: assets)];
final actions = [AssetDebugAction(assets: assets)];
return BaseBottomSheet(
controller: sheetController,
@@ -106,6 +107,8 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
if (multiselect.onlyLocal || multiselect.hasMerged) const DeleteActionButton(source: ActionSource.timeline),
],
if (multiselect.onlyLocal || multiselect.hasMerged)
@@ -5,7 +5,6 @@ import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
@@ -18,7 +17,9 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_al
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
@@ -85,7 +86,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [FavoriteAction(assets: assets), StackAction(assets: assets)];
final actions = [FavoriteAction(assets: assets)];
return BaseBottomSheet(
controller: sheetController,
@@ -110,6 +111,8 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
],
],
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
@@ -22,6 +22,8 @@ class MultiSelectState {
bool get hasRemote =>
selectedAssets.any((asset) => asset.storage == AssetState.remote || asset.storage == AssetState.merged);
bool get hasStacked => selectedAssets.any((asset) => asset is RemoteAsset && asset.stackId != null);
bool get hasMerged => selectedAssets.any((asset) => asset.storage == AssetState.merged);
bool get onlyLocal => selectedAssets.any((asset) => asset.storage == AssetState.local);
+2 -2
View File
@@ -9,7 +9,6 @@ import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
@@ -31,6 +30,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos
import 'package:immich_mobile/presentation/widgets/action_buttons/slideshow_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -248,7 +248,7 @@ enum ActionButtonType {
menuItem: menuItem,
),
ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.unstack => ActionMenuItemWidget(action: StackAction(assets: [context.asset])),
ActionButtonType.unstack => UnStackActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.openInBrowser => OpenInBrowserActionButton(
remoteId: context.asset.remoteId!,
origin: context.timelineOrigin,
@@ -5,7 +5,7 @@ import '../../utils.dart';
class RemoteAssetFactory {
const RemoteAssetFactory();
static RemoteAsset create({String? id, String? name, String? ownerId, bool isFavorite = false, String? stackId}) {
static RemoteAsset create({String? id, String? name, String? ownerId, bool isFavorite = false}) {
id = TestUtils.uuid(id);
return RemoteAsset(
@@ -17,7 +17,6 @@ class RemoteAssetFactory {
createdAt: TestUtils.yesterday(),
updatedAt: TestUtils.now(),
isFavorite: isFavorite,
stackId: stackId,
isEdited: false,
);
}
+14 -64
View File
@@ -1,10 +1,7 @@
import 'dart:typed_data';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:mocktail/mocktail.dart' as mock;
import 'package:mocktail/mocktail.dart';
@@ -15,11 +12,11 @@ import 'factories/local_asset_factory.dart';
import 'factories/user_factory.dart';
class RepositoryMocks {
final localAlbum = LocalAlbumRepositoryStub(MockLocalAlbumRepository());
final localAsset = LocalAssetRepositoryStub(MockDriftLocalAssetRepository());
final localAlbum = MockLocalAlbumRepository();
final localAsset = MockDriftLocalAssetRepository();
final trashedAsset = MockTrashedLocalAssetRepository();
final nativeApi = NativeSyncApiStub(MockNativeSyncApi());
final nativeApi = MockNativeSyncApi();
RepositoryMocks() {
resetAll();
@@ -27,34 +24,17 @@ class RepositoryMocks {
void resetAll() {
_registerFallbacks();
localAlbum.reset();
localAsset.reset();
reset(localAlbum);
reset(localAsset);
reset(trashedAsset);
nativeApi.reset();
_stubLocalAlbumRepository();
_stubLocalAssetRepository();
_stubNativeSyncApi();
}
void _stubLocalAlbumRepository() {
when(localAlbum.getBackupAlbums).thenAnswer((_) async => []);
when(localAlbum.getAssetsToHash).thenAnswer((_) async => []);
}
void _stubLocalAssetRepository() {
when(localAsset.reconcileHashesFromCloudId).thenAnswer((_) async => {});
when(localAsset.updateHashes).thenAnswer((_) async => {});
}
void _stubNativeSyncApi() {
when(nativeApi.hashAssets).thenAnswer((_) async => []);
reset(nativeApi);
}
}
class ServiceMocks {
final partner = PartnerServiceStub(MockPartnerService());
final user = UserServiceStub(MockUserService());
final asset = AssetServiceStub(MockAssetService());
final PartnerStub partner = PartnerStub(MockPartnerService());
final UserStub user = UserStub(MockUserService());
final asset = AssetStub(MockAssetService());
ServiceMocks() {
resetAll();
@@ -89,8 +69,6 @@ class ServiceMocks {
void _stubAssetService() {
when(asset.updateFavorite).thenAnswer((_) async {});
when(asset.stack).thenAnswer((_) async {});
when(asset.unStack).thenAnswer((_) async {});
}
}
@@ -100,28 +78,11 @@ void _registerFallbacks() {
registerFallbackValue(Uint8List(0));
}
extension type const Stub<T extends Mock>(T mockedClass) {
void reset() => mock.reset(mockedClass);
extension type const Stub<T extends Mock>(T mockedService) {
void reset() => mock.reset(mockedService);
}
extension type const LocalAlbumRepositoryStub(MockLocalAlbumRepository repo) implements Stub<MockLocalAlbumRepository> {
Future<List<LocalAlbum>> Function() get getBackupAlbums =>
() => repo.getBackupAlbums();
Future<List<LocalAsset>> Function() get getAssetsToHash =>
() => repo.getAssetsToHash(any());
}
extension type const LocalAssetRepositoryStub(MockDriftLocalAssetRepository repo)
implements Stub<MockDriftLocalAssetRepository> {
Future<void> Function() get reconcileHashesFromCloudId =>
() => repo.reconcileHashesFromCloudId();
Future<void> Function() get updateHashes =>
() => repo.updateHashes(any());
}
extension type const PartnerServiceStub(MockPartnerService service) implements Stub<MockPartnerService> {
extension type const PartnerStub(MockPartnerService service) implements Stub<MockPartnerService> {
Stream<Iterable<User>> Function() get getCandidates =>
() => service.getCandidates(any());
@@ -149,7 +110,7 @@ extension type const PartnerServiceStub(MockPartnerService service) implements S
);
}
extension type const UserServiceStub(MockUserService service) implements Stub<MockUserService> {
extension type const UserStub(MockUserService service) implements Stub<MockUserService> {
UserDto Function() get getMyUser =>
() => service.getMyUser();
@@ -166,18 +127,7 @@ extension type const UserServiceStub(MockUserService service) implements Stub<Mo
() => service.createProfileImage(any(), any());
}
extension type const AssetServiceStub(MockAssetService service) implements Stub<MockAssetService> {
extension type const AssetStub(MockAssetService service) implements Stub<MockAssetService> {
Future<void> Function() get updateFavorite =>
() => service.updateFavorite(any(), any());
Future<void> Function() get stack =>
() => service.stack(any(), any());
Future<void> Function() get unStack =>
() => service.unStack(any());
}
extension type const NativeSyncApiStub(MockNativeSyncApi api) implements Stub<MockNativeSyncApi> {
Future<List<HashResult>> Function() get hashAssets =>
() => api.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'));
}
@@ -6,7 +6,7 @@ import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
import 'package:immich_ui/immich_ui.dart';
import '../../factories/remote_asset_factory.dart';
import '../presentation_context.dart';
import '../../presentation_context.dart';
void main() {
late PresentationContext context;
@@ -23,8 +23,8 @@ void main() {
group('AssetDebugAction', () {
testWidgets('visible for a single asset when advanced troubleshooting is on', (tester) async {
await tester.pumpTestWidget(
context,
ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])),
overrides: context.overrides,
);
expect(find.byType(ImmichIconButton), findsOneWidget);
@@ -32,10 +32,10 @@ void main() {
testWidgets('hidden for multiple assets', (tester) async {
await tester.pumpTestWidget(
context,
ActionIconButtonWidget(
action: AssetDebugAction(assets: [RemoteAssetFactory.create(), RemoteAssetFactory.create()]),
),
overrides: context.overrides,
);
expect(find.byType(ImmichIconButton), findsNothing);
@@ -44,8 +44,8 @@ void main() {
testWidgets('hidden when advanced troubleshooting is off', (tester) async {
await StoreService.I.put(StoreKey.advancedTroubleshooting, false);
await tester.pumpTestWidget(
context,
ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])),
overrides: context.overrides,
);
expect(find.byType(ImmichIconButton), findsNothing);
@@ -1,26 +1,30 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:mocktail/mocktail.dart';
import '../../../domain/service.mock.dart';
import '../../factories/remote_asset_factory.dart';
import '../presentation_context.dart';
import '../../presentation_context.dart';
void main() {
late PresentationContext context;
late MockAssetService assetService;
setUp(() async {
context = await PresentationContext.create();
assetService = context.service.asset.service;
});
tearDown(() {
context.dispose();
});
List<Override> overrides() => [
...context.overrides,
assetServiceProvider.overrideWithValue(context.mocks.asset.service),
];
RemoteAsset owned({bool isFavorite = false}) =>
RemoteAssetFactory.create(ownerId: context.currentUser.id, isFavorite: isFavorite);
@@ -28,48 +32,48 @@ void main() {
testWidgets('favorites the eligible owned assets', (tester) async {
final asset = owned();
await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
await tester.pumpTestAction(FavoriteAction(assets: [asset]), overrides: overrides());
verify(() => assetService.updateFavorite([asset.id], true)).called(1);
verify(() => context.mocks.asset.service.updateFavorite([asset.id], true)).called(1);
});
testWidgets('unfavorite the eligible owned assets', (tester) async {
final asset = owned(isFavorite: true);
await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
await tester.pumpTestAction(FavoriteAction(assets: [asset]), overrides: overrides());
verify(() => assetService.updateFavorite([asset.id], false)).called(1);
verify(() => context.mocks.asset.service.updateFavorite([asset.id], false)).called(1);
});
testWidgets('ignores assets owned by someone else', (tester) async {
final mine = owned();
final theirs = RemoteAssetFactory.create();
await tester.pumpTestAction(context, FavoriteAction(assets: [mine, theirs]));
await tester.pumpTestAction(FavoriteAction(assets: [mine, theirs]), overrides: overrides());
verify(() => assetService.updateFavorite([mine.id], true)).called(1);
verify(() => context.mocks.asset.service.updateFavorite([mine.id], true)).called(1);
});
testWidgets('batches every eligible owned asset into a single call', (tester) async {
final first = owned();
final second = owned();
await tester.pumpTestAction(context, FavoriteAction(assets: [first, second]));
await tester.pumpTestAction(FavoriteAction(assets: [first, second]), overrides: overrides());
verify(() => assetService.updateFavorite([first.id, second.id], true)).called(1);
verify(() => context.mocks.asset.service.updateFavorite([first.id, second.id], true)).called(1);
});
testWidgets('skips owned assets already in the target state', (tester) async {
final stale = owned();
final alreadyFavorite = owned(isFavorite: true);
await tester.pumpTestAction(context, FavoriteAction(assets: [stale, alreadyFavorite]));
await tester.pumpTestAction(FavoriteAction(assets: [stale, alreadyFavorite]), overrides: overrides());
verify(() => assetService.updateFavorite([stale.id], true)).called(1);
verify(() => context.mocks.asset.service.updateFavorite([stale.id], true)).called(1);
});
testWidgets('shows a confirmation snackbar on success', (tester) async {
await tester.pumpTestAction(context, FavoriteAction(assets: [owned()]));
await tester.pumpTestAction(FavoriteAction(assets: [owned()]), overrides: overrides());
await tester.pumpUntilFound(find.byType(SnackBar));
expect(find.byType(SnackBar), findsOneWidget);
@@ -4,19 +4,17 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/presentation/actions/partner.action.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:mocktail/mocktail.dart';
import '../../../domain/service.mock.dart';
import '../../factories/user_factory.dart';
import '../presentation_context.dart';
import '../../presentation_context.dart';
void main() {
late PresentationContext context;
late MockPartnerService partnerService;
setUp(() async {
context = await PresentationContext.create();
partnerService = context.service.partner.service;
});
tearDown(() {
@@ -24,6 +22,8 @@ void main() {
});
List<Override> overrides({List<User> candidates = const []}) => [
...context.overrides,
partnerServiceProvider.overrideWithValue(context.mocks.partner.service),
candidatesStateProvider.overrideWith((ref) => Stream<Iterable<User>>.value(candidates)),
];
@@ -31,24 +31,22 @@ void main() {
testWidgets('creates a partner for the selected candidate', (tester) async {
final candidate = UserFactory.create();
await tester.pumpTestAction(context, const PartnerAddAction(), overrides: overrides(candidates: [candidate]));
await tester.pumpTestAction(const PartnerAddAction(), overrides: overrides(candidates: [candidate]));
await tester.pumpUntilFound(find.text(candidate.name));
await tester.tap(find.text(candidate.name));
await tester.pumpAndSettle();
verify(() => partnerService.create(sharedById: context.currentUser.id, sharedWithId: candidate.id)).called(1);
verify(
() => context.mocks.partner.service.create(sharedById: context.currentUser.id, sharedWithId: candidate.id),
).called(1);
});
testWidgets('creates nothing when the selection dialog is dismissed', (tester) async {
await tester.pumpTestAction(
context,
const PartnerAddAction(),
overrides: overrides(candidates: [UserFactory.create()]),
);
await tester.pumpTestAction(const PartnerAddAction(), overrides: overrides(candidates: [UserFactory.create()]));
await tester.sendKeyEvent(LogicalKeyboardKey.escape); // dismiss without selecting
await tester.pumpAndSettle();
verifyNever(context.service.partner.create);
verifyNever(context.mocks.partner.create);
});
});
@@ -56,27 +54,27 @@ void main() {
testWidgets('deletes the partner after confirmation', (tester) async {
final partner = UserFactory.create();
await tester.pumpTestAction(
context,
PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
overrides: overrides(),
);
await tester.tap(find.byType(TextButton).last); // confirm
await tester.pumpAndSettle();
verify(() => partnerService.delete(sharedById: context.currentUser.id, sharedWithId: partner.id)).called(1);
verify(
() => context.mocks.partner.service.delete(sharedById: context.currentUser.id, sharedWithId: partner.id),
).called(1);
});
testWidgets('deletes nothing when the confirmation is cancelled', (tester) async {
final partner = UserFactory.create();
await tester.pumpTestAction(
context,
PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
overrides: overrides(),
);
await tester.tap(find.byType(TextButton).first); // cancel
await tester.pumpAndSettle();
verifyNever(context.service.partner.delete);
verifyNever(context.mocks.partner.delete);
});
});
}
@@ -1,79 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
import 'package:mocktail/mocktail.dart';
import '../../../domain/service.mock.dart';
import '../../factories/remote_asset_factory.dart';
import '../presentation_context.dart';
void main() {
late PresentationContext context;
late MockAssetService assetService;
setUp(() async {
context = await PresentationContext.create();
assetService = context.service.asset.service;
});
tearDown(() {
context.dispose();
});
RemoteAsset owned({String? stackId}) => RemoteAssetFactory.create(ownerId: context.currentUser.id, stackId: stackId);
group('StackAction', () {
testWidgets('stacks the eligible owned assets', (tester) async {
final first = owned();
final second = owned();
await tester.pumpTestAction(context, StackAction(assets: [first, second]));
verify(() => assetService.stack(context.currentUser.id, [first.id, second.id])).called(1);
});
testWidgets('unstacks the eligible owned assets', (tester) async {
final asset = owned(stackId: 'stack');
await tester.pumpTestAction(context, StackAction(assets: [asset]));
verify(() => assetService.unStack(['stack'])).called(1);
});
testWidgets('prioritizes stack when mixed state', (tester) async {
final first = owned();
final second = owned(stackId: 'stack');
await tester.pumpTestAction(context, StackAction(assets: [first, second]));
verify(() => assetService.stack(context.currentUser.id, [first.id, second.id])).called(1);
});
testWidgets('ignores assets owned by someone else', (tester) async {
final mine = owned();
final other = owned();
final theirs = RemoteAssetFactory.create();
await tester.pumpTestAction(context, StackAction(assets: [mine, other, theirs]));
verify(() => assetService.stack(context.currentUser.id, [mine.id, other.id])).called(1);
});
testWidgets('unstacks every selected stack in a single call', (tester) async {
final first = owned(stackId: 'stack-1');
final second = owned(stackId: 'stack-2');
await tester.pumpTestAction(context, StackAction(assets: [first, second]));
verify(() => assetService.unStack(['stack-1', 'stack-2'])).called(1);
});
testWidgets('shows a confirmation snackbar on success', (tester) async {
await tester.pumpTestAction(context, StackAction(assets: [owned(), owned()]));
await tester.pumpUntilFound(find.byType(SnackBar));
expect(find.byType(SnackBar), findsOneWidget);
});
});
}
@@ -7,7 +7,7 @@ import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import '../../factories/remote_asset_factory.dart';
import '../presentation_context.dart';
import '../../presentation_context.dart';
class _FakeAction extends BaseAction {
_FakeAction({this.visible = true, this.error});
@@ -48,7 +48,8 @@ void main() {
context.dispose();
});
List<Override> overrides() => [
List<Override> seededOverrides() => [
...context.overrides,
multiSelectProvider.overrideWith(
() => MultiSelectNotifier(
MultiSelectState(selectedAssets: {RemoteAssetFactory.create()}, lockedSelectionAssets: const {}),
@@ -60,7 +61,6 @@ void main() {
late ActionScope scope;
late ProviderContainer container;
await tester.pumpTestWidget(
context,
Consumer(
builder: (innerContext, ref, _) {
scope = ActionScope(context: innerContext, ref: ref, authUser: context.currentUser);
@@ -68,7 +68,7 @@ void main() {
return const SizedBox.shrink();
},
),
overrides: overrides(),
overrides: seededOverrides(),
);
return (scope, container);
}
@@ -97,8 +97,8 @@ void main() {
testWidgets('delegates visibility to the wrapped action', (tester) async {
await tester.pumpTestWidget(
context,
ActionIconButtonWidget(action: TimelineAction(action: _FakeAction(visible: false))),
overrides: context.overrides,
);
expect(find.byType(ActionIconButtonWidget), findsOneWidget);
@@ -7,7 +7,7 @@ import 'package:immich_mobile/presentation/actions/partner.action.dart';
import '../factories/partner_user_factory.dart';
import '../factories/user_factory.dart';
import 'presentation_context.dart';
import '../presentation_context.dart';
void main() {
late PresentationContext context;
@@ -19,7 +19,7 @@ void main() {
testWidgets('shows the empty-state add button when there are no partners', (tester) async {
final action = const PartnerAddAction();
await tester.pumpTestWidget(context, const PartnerSharedByList(partners: []));
await tester.pumpTestWidget(const PartnerSharedByList(partners: []), overrides: context.overrides);
expect(find.byType(ListView), findsNothing);
expect(find.widgetWithIcon(TextButton, action.icon), findsOneWidget);
@@ -28,7 +28,8 @@ void main() {
testWidgets('renders a tile per partner with name and email', (tester) async {
final partner1 = PartnerFactory.create();
final partner2 = PartnerFactory.create();
await tester.pumpTestWidget(context, PartnerSharedByList(partners: [partner1, partner2]));
await tester.pumpTestWidget(PartnerSharedByList(partners: [partner1, partner2]), overrides: context.overrides);
expect(find.byType(ListTile), findsNWidgets(2));
expect(find.text(partner1.name), findsOneWidget);
expect(find.text(partner1.email), findsOneWidget);
@@ -40,7 +41,7 @@ void main() {
final partner1 = PartnerFactory.create(inTimeline: true);
final partner2 = PartnerFactory.create();
final action = const PartnerRemoveAction(sharedWithId: '', partnerName: '');
await tester.pumpTestWidget(context, PartnerSharedByList(partners: [partner1, partner2]));
await tester.pumpTestWidget(PartnerSharedByList(partners: [partner1, partner2]), overrides: context.overrides);
expect(find.byIcon(action.icon), findsNWidgets(2));
});
});
@@ -61,12 +62,13 @@ void main() {
}
List<Override> withCandidates(List<User> candidates) => [
...context.overrides,
candidatesStateProvider.overrideWith((ref) => Stream<Iterable<User>>.value(candidates)),
];
testWidgets('renders an option per candidate fetched from the provider', (tester) async {
final user = UserFactory.create();
await tester.pumpTestWidget(context, dialogWidget(), overrides: withCandidates([user]));
await tester.pumpTestWidget(dialogWidget(), overrides: withCandidates([user]));
await tester.tap(find.byKey(dialogButtonKey));
await tester.pumpAndSettle();
@@ -76,7 +78,7 @@ void main() {
});
testWidgets('shows no options when the provider returns no candidates', (tester) async {
await tester.pumpTestWidget(context, dialogWidget(), overrides: withCandidates(const []));
await tester.pumpTestWidget(dialogWidget(), overrides: withCandidates(const []));
await tester.tap(find.byKey(dialogButtonKey));
await tester.pumpAndSettle();
@@ -87,11 +89,7 @@ void main() {
testWidgets('pops the selected candidate when an option is tapped', (tester) async {
final user = UserFactory.create();
User? selected;
await tester.pumpTestWidget(
context,
dialogWidget(onClosed: (user) => selected = user),
overrides: withCandidates([user]),
);
await tester.pumpTestWidget(dialogWidget(onClosed: (user) => selected = user), overrides: withCandidates([user]));
await tester.tap(find.byKey(dialogButtonKey));
await tester.pumpAndSettle();
@@ -13,21 +13,16 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:mocktail/mocktail.dart';
import '../../test_utils.dart';
import '../factories/user_factory.dart';
import '../mocks.dart';
import '../test_utils.dart';
import 'factories/user_factory.dart';
import 'mocks.dart';
class PresentationContext {
PresentationContext._({required UserDto user})
: currentUser = user,
service = ServiceMocks(),
repository = RepositoryMocks() {
PresentationContext._({required UserDto user}) : currentUser = user, mocks = ServiceMocks() {
setup();
}
@@ -36,14 +31,9 @@ class PresentationContext {
static Drift? _db;
final UserDto currentUser;
final ServiceMocks service;
final RepositoryMocks repository;
final ServiceMocks mocks;
List<Override> get overrides => [
currentUserProvider.overrideWith((ref) => CurrentUserProvider(service.user.service)),
assetServiceProvider.overrideWithValue(service.asset.service),
partnerServiceProvider.overrideWithValue(service.partner.service),
];
List<Override> get overrides => [currentUserProvider.overrideWith((ref) => CurrentUserProvider(mocks.user.service))];
static Future<PresentationContext> create() async {
TestUtils.init();
@@ -57,18 +47,18 @@ class PresentationContext {
}
void setup() {
when(service.user.tryGetMyUser).thenReturn(currentUser);
when(mocks.user.tryGetMyUser).thenReturn(currentUser);
}
void dispose() {
addTearDown(() {
service.resetAll();
mocks.resetAll();
});
}
}
extension PumpPresentationWidget on WidgetTester {
Future<void> pumpTestWidget(PresentationContext context, Widget widget, {List<Override> overrides = const []}) async {
Future<void> pumpTestWidget(Widget widget, {List<Override> overrides = const []}) async {
await pumpWidget(
EasyLocalization(
supportedLocales: locales.values.toList(),
@@ -79,7 +69,7 @@ extension PumpPresentationWidget on WidgetTester {
useFallbackTranslations: true,
assetLoader: const CodegenLoader(),
child: ProviderScope(
overrides: [...context.overrides, ...overrides],
overrides: overrides,
child: Builder(
builder: (context) => MaterialApp(
debugShowCheckedModeBanner: false,
@@ -96,12 +86,8 @@ extension PumpPresentationWidget on WidgetTester {
await pumpAndSettle();
}
Future<void> pumpTestAction(
PresentationContext context,
BaseAction action, {
List<Override> overrides = const [],
}) async {
await pumpTestWidget(context, ActionIconButtonWidget(action: action), overrides: overrides);
Future<void> pumpTestAction(BaseAction action, {List<Override> overrides = const []}) async {
await pumpTestWidget(ActionIconButtonWidget(action: action), overrides: overrides);
await tap(find.byType(ImmichIconButton));
await pump();
}
@@ -14,11 +14,14 @@ void main() {
setUp(() {
sut = HashService(
localAlbumRepository: mocks.localAlbum.repo,
localAssetRepository: mocks.localAsset.repo,
nativeSyncApi: mocks.nativeApi.api,
localAlbumRepository: mocks.localAlbum,
localAssetRepository: mocks.localAsset,
nativeSyncApi: mocks.nativeApi,
trashedLocalAssetRepository: mocks.trashedAsset,
);
when(() => mocks.localAsset.reconcileHashesFromCloudId()).thenAnswer((_) async => {});
when(() => mocks.localAsset.updateHashes(any())).thenAnswer((_) async => {});
});
tearDown(() {
@@ -29,20 +32,22 @@ void main() {
group('hashAssets', () {
test('skips albums with no assets to hash', () async {
final album = LocalAlbumFactory.create(assetCount: 0);
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => []);
await sut.hashAssets();
verifyNever(mocks.nativeApi.hashAssets);
verifyNever(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('skips empty batches', () async {
final album = LocalAlbumFactory.create();
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => []);
await sut.hashAssets();
verifyNever(mocks.nativeApi.hashAssets);
verifyNever(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('processes assets when available', () async {
@@ -50,17 +55,15 @@ void main() {
final asset = LocalAssetFactory.create();
final result = HashResult(assetId: asset.id, hash: 'test-hash');
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(
() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false),
).thenAnswer((_) async => [result]);
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false)).thenAnswer((_) async => [result]);
await sut.hashAssets();
verify(() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false)).called(1);
verify(() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false)).called(1);
final captured =
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
expect(captured.length, 1);
expect(captured[asset.id], result.hash);
});
@@ -69,16 +72,16 @@ void main() {
final album = LocalAlbumFactory.create();
final asset = LocalAssetFactory.create();
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(
() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false),
() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false),
).thenAnswer((_) async => [HashResult(assetId: asset.id, error: 'Failed to hash')]);
await sut.hashAssets();
final captured =
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
expect(captured.length, 0);
});
@@ -86,25 +89,25 @@ void main() {
final album = LocalAlbumFactory.create();
final asset = LocalAssetFactory.create();
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(
() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false),
() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false),
).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: null)]);
await sut.hashAssets();
final captured =
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
expect(captured.length, 0);
});
test('batches by size limit', () async {
const batchSize = 2;
final sut = HashService(
localAlbumRepository: mocks.localAlbum.repo,
localAssetRepository: mocks.localAsset.repo,
nativeSyncApi: mocks.nativeApi.api,
localAlbumRepository: mocks.localAlbum,
localAssetRepository: mocks.localAsset,
nativeSyncApi: mocks.nativeApi,
batchSize: batchSize,
trashedLocalAssetRepository: mocks.trashedAsset,
);
@@ -116,9 +119,12 @@ void main() {
final capturedCalls = <List<String>>[];
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2, asset3]);
when(mocks.nativeApi.hashAssets).thenAnswer((invocation) async {
when(() => mocks.localAsset.updateHashes(any())).thenAnswer((_) async => {});
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2, asset3]);
when(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))).thenAnswer((
invocation,
) async {
final assetIds = invocation.positionalArguments[0] as List<String>;
capturedCalls.add(List<String>.from(assetIds));
return assetIds.map((id) => HashResult(assetId: id, hash: '$id-hash')).toList();
@@ -130,7 +136,7 @@ void main() {
expect(capturedCalls[0], [asset1.id, asset2.id], reason: 'First call should batch the first two assets');
expect(capturedCalls[1], [asset3.id], reason: 'Second call should have the remaining asset');
verify(() => mocks.localAsset.repo.updateHashes(any())).called(2);
verify(() => mocks.localAsset.updateHashes(any())).called(2);
});
test('handles mixed success and failure in batch', () async {
@@ -138,9 +144,9 @@ void main() {
final asset1 = LocalAssetFactory.create();
final asset2 = LocalAssetFactory.create();
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2]);
when(() => mocks.nativeApi.api.hashAssets([asset1.id, asset2.id], allowNetworkAccess: false)).thenAnswer(
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2]);
when(() => mocks.nativeApi.hashAssets([asset1.id, asset2.id], allowNetworkAccess: false)).thenAnswer(
(_) async => [
HashResult(assetId: asset1.id, hash: 'asset1-hash'),
HashResult(assetId: asset2.id, error: 'Failed to hash asset2'),
@@ -150,7 +156,7 @@ void main() {
await sut.hashAssets();
final captured =
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
expect(captured.length, 1);
expect(captured[asset1.id], 'asset1-hash');
});
@@ -161,18 +167,20 @@ void main() {
final asset1 = LocalAssetFactory.create();
final asset2 = LocalAssetFactory.create();
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [selectedAlbum, nonSelectedAlbum]);
when(() => mocks.localAlbum.repo.getAssetsToHash(selectedAlbum.id)).thenAnswer((_) async => [asset1]);
when(() => mocks.localAlbum.repo.getAssetsToHash(nonSelectedAlbum.id)).thenAnswer((_) async => [asset2]);
when(mocks.nativeApi.hashAssets).thenAnswer((invocation) async {
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [selectedAlbum, nonSelectedAlbum]);
when(() => mocks.localAlbum.getAssetsToHash(selectedAlbum.id)).thenAnswer((_) async => [asset1]);
when(() => mocks.localAlbum.getAssetsToHash(nonSelectedAlbum.id)).thenAnswer((_) async => [asset2]);
when(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))).thenAnswer((
invocation,
) async {
final assetIds = invocation.positionalArguments[0] as List<String>;
return assetIds.map((id) => HashResult(assetId: id, hash: '$id-hash')).toList();
});
await sut.hashAssets();
verify(() => mocks.nativeApi.api.hashAssets([asset1.id], allowNetworkAccess: true)).called(1);
verify(() => mocks.nativeApi.api.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
verify(() => mocks.nativeApi.hashAssets([asset1.id], allowNetworkAccess: true)).called(1);
verify(() => mocks.nativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
});
});
});