From aadb0c0c95a88003922a3df646c38ca6e0d5f45a Mon Sep 17 00:00:00 2001 From: Mubarak Auwal Date: Mon, 18 May 2026 22:15:08 +0100 Subject: [PATCH 01/15] feat(blocked-users): complete block/unblock UX --- CHANGELOG.md | 1 + lib/l10n/app_de.arb | 7 + lib/l10n/app_en.arb | 28 ++ lib/l10n/app_es.arb | 7 + lib/l10n/app_fr.arb | 7 + lib/l10n/app_it.arb | 7 + lib/l10n/app_pt.arb | 7 + lib/l10n/app_ru.arb | 7 + lib/l10n/app_tr.arb | 7 + lib/l10n/app_zh.arb | 7 + lib/l10n/app_zh_Hant.arb | 7 + lib/l10n/generated/app_localizations.dart | 42 +++ lib/l10n/generated/app_localizations_de.dart | 23 ++ lib/l10n/generated/app_localizations_en.dart | 22 ++ lib/l10n/generated/app_localizations_es.dart | 23 ++ lib/l10n/generated/app_localizations_fr.dart | 23 ++ lib/l10n/generated/app_localizations_it.dart | 22 ++ lib/l10n/generated/app_localizations_pt.dart | 23 ++ lib/l10n/generated/app_localizations_ru.dart | 24 ++ lib/l10n/generated/app_localizations_tr.dart | 23 ++ lib/l10n/generated/app_localizations_zh.dart | 42 +++ lib/routes.dart | 31 +++ lib/screens/blocked_user_screen.dart | 170 ++++++++++++ lib/screens/blocked_users_screen.dart | 122 +++++++++ lib/screens/chat_screen.dart | 6 +- lib/screens/privacy_security_screen.dart | 26 ++ test/screens/blocked_user_screen_test.dart | 241 ++++++++++++++++++ test/screens/blocked_users_screen_test.dart | 184 +++++++++++++ test/screens/chat_screen_test.dart | 19 ++ .../screens/privacy_security_screen_test.dart | 19 ++ 30 files changed, 1174 insertions(+), 3 deletions(-) create mode 100644 lib/screens/blocked_user_screen.dart create mode 100644 lib/screens/blocked_users_screen.dart create mode 100644 test/screens/blocked_user_screen_test.dart create mode 100644 test/screens/blocked_users_screen_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index e5a3ad681..1f86bec43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to Calendar Versioning (CalVer). ### Added - Leave group from chat list for non-last admins [PR #638](https://github.com/marmot-protocol/whitenoise/pull/638) - Add archive option in chat removed warning and change wording for leave case [PR #657](https://github.com/marmot-protocol/whitenoise/pull/657) +- Hide chat composer when peer is blocked and add Blocked users management under Settings → Privacy & Security ### Changed diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index be333cea4..914799543 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -295,6 +295,13 @@ "failedToUnblockUser": "Blockierung konnte nicht aufgehoben werden. Bitte erneut versuchen.", "userIsBlocked": "Du hast diesen Benutzer blockiert", "userIsBlockedDescription": "Du erhältst keine neuen Nachrichten, bis du die Blockierung aufhebst.", + "blockedUsers": "Blockierte Nutzer", + "viewBlockedUsers": "Blockierte Nutzer anzeigen", + "blockedUsersDescription": "Personen, die du blockiert hast, ansehen und verwalten.", + "blockedUsersEmpty": "Du hast noch niemanden blockiert.", + "failedToFetchBlockedUsers": "Blockierte Nutzer konnten nicht geladen werden. Bitte erneut versuchen.", + "blockedUserProfileTitle": "Profil", + "blockedUserDetailDescription": "Du hast diesen Nutzer blockiert. Du kannst keine Nachrichten senden, bis die Blockierung aufgehoben ist.", "relayResolutionTitle": "Relay-Einrichtung", "relayResolutionDescription": "Wir konnten Ihre Relay-Listen nicht im Netzwerk finden. Sie können ein Relay angeben, auf dem Ihre Listen veröffentlicht sind, oder unsere Standard-Relays verwenden, um loszulegen.", "relayResolutionUseDefaults": "Standard-Relays verwenden", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4ed20159c..dd3d73b2c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1308,6 +1308,34 @@ "@userIsBlockedDescription": { "description": "Notice description shown in chat header when the peer is blocked" }, + "blockedUsers": "Blocked users", + "@blockedUsers": { + "description": "Section label and screen title for the list of users the account has blocked" + }, + "viewBlockedUsers": "View blocked users", + "@viewBlockedUsers": { + "description": "Button label that opens the blocked users management screen" + }, + "blockedUsersDescription": "View and manage people you've blocked.", + "@blockedUsersDescription": { + "description": "Helper text under the View blocked users button on the privacy & security screen" + }, + "blockedUsersEmpty": "You haven't blocked anyone yet.", + "@blockedUsersEmpty": { + "description": "Empty state message shown when the blocked users list is empty" + }, + "failedToFetchBlockedUsers": "Failed to load blocked users. Please try again.", + "@failedToFetchBlockedUsers": { + "description": "Error message shown when the blocked users list cannot be loaded" + }, + "blockedUserProfileTitle": "Profile", + "@blockedUserProfileTitle": { + "description": "Title for the blocked user profile detail screen" + }, + "blockedUserDetailDescription": "You've blocked this user. You won't be able to send messages until you unblock them.", + "@blockedUserDetailDescription": { + "description": "Description shown in the blocked user profile screen banner" + }, "addToAnotherGroup": "Add to another group", "@addToAnotherGroup": { "description": "Action label to add this user to another group" diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index b0456b5cf..029b27da7 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -295,6 +295,13 @@ "failedToUnblockUser": "No se pudo desbloquear al usuario. Por favor, inténtalo de nuevo.", "userIsBlocked": "Has bloqueado a este usuario", "userIsBlockedDescription": "No recibirás nuevos mensajes hasta que lo desbloquees.", + "blockedUsers": "Usuarios bloqueados", + "viewBlockedUsers": "Ver usuarios bloqueados", + "blockedUsersDescription": "Ver y gestionar las personas que has bloqueado.", + "blockedUsersEmpty": "Aún no has bloqueado a nadie.", + "failedToFetchBlockedUsers": "No se pudieron cargar los usuarios bloqueados. Inténtalo de nuevo.", + "blockedUserProfileTitle": "Perfil", + "blockedUserDetailDescription": "Has bloqueado a este usuario. No podrás enviar mensajes hasta que lo desbloquees.", "relayResolutionTitle": "Configuración de relé", "relayResolutionDescription": "No pudimos encontrar tus listas de relés en la red. Puedes proporcionar un relé donde estén publicadas tus listas o usar nuestros relés predeterminados para comenzar.", "relayResolutionUseDefaults": "Usar relés predeterminados", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index ec0e3cbd7..edaf597e5 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -295,6 +295,13 @@ "failedToUnblockUser": "Impossible de débloquer l'utilisateur. Veuillez réessayer.", "userIsBlocked": "Vous avez bloqué cet utilisateur", "userIsBlockedDescription": "Vous ne recevrez pas de nouveaux messages tant que vous ne le débloquez pas.", + "blockedUsers": "Utilisateurs bloqués", + "viewBlockedUsers": "Voir les utilisateurs bloqués", + "blockedUsersDescription": "Voir et gérer les personnes que vous avez bloquées.", + "blockedUsersEmpty": "Vous n’avez bloqué personne pour l’instant.", + "failedToFetchBlockedUsers": "Impossible de charger les utilisateurs bloqués. Veuillez réessayer.", + "blockedUserProfileTitle": "Profil", + "blockedUserDetailDescription": "Vous avez bloqué cet utilisateur. Vous ne pourrez pas envoyer de messages avant de le débloquer.", "relayResolutionTitle": "Configuration du relais", "relayResolutionDescription": "Nous n'avons pas trouvé vos listes de relais sur le réseau. Vous pouvez fournir un relais où vos listes sont publiées ou utiliser nos relais par défaut pour commencer.", "relayResolutionUseDefaults": "Utiliser les relais par défaut", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index d858def81..f2b8555a5 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -295,6 +295,13 @@ "failedToUnblockUser": "Impossibile sbloccare l'utente. Riprova.", "userIsBlocked": "Hai bloccato questo utente", "userIsBlockedDescription": "Non riceverai nuovi messaggi finché non lo sblocchi.", + "blockedUsers": "Utenti bloccati", + "viewBlockedUsers": "Vedi utenti bloccati", + "blockedUsersDescription": "Visualizza e gestisci le persone che hai bloccato.", + "blockedUsersEmpty": "Non hai ancora bloccato nessuno.", + "failedToFetchBlockedUsers": "Impossibile caricare gli utenti bloccati. Riprova.", + "blockedUserProfileTitle": "Profilo", + "blockedUserDetailDescription": "Hai bloccato questo utente. Non potrai inviare messaggi finché non lo sblocchi.", "relayResolutionTitle": "Configurazione relay", "relayResolutionDescription": "Non abbiamo trovato le tue liste di relay sulla rete. Puoi fornire un relay dove sono pubblicate le tue liste oppure utilizzare i nostri relay predefiniti per iniziare.", "relayResolutionUseDefaults": "Usa relay predefiniti", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 4e9f8e012..03e5edca0 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -295,6 +295,13 @@ "failedToUnblockUser": "Não foi possível desbloquear o usuário. Por favor, tente novamente.", "userIsBlocked": "Você bloqueou este usuário", "userIsBlockedDescription": "Você não receberá novas mensagens até desbloquear este usuário.", + "blockedUsers": "Usuários bloqueados", + "viewBlockedUsers": "Ver usuários bloqueados", + "blockedUsersDescription": "Veja e gerencie as pessoas que você bloqueou.", + "blockedUsersEmpty": "Você ainda não bloqueou ninguém.", + "failedToFetchBlockedUsers": "Não foi possível carregar os usuários bloqueados. Tente novamente.", + "blockedUserProfileTitle": "Perfil", + "blockedUserDetailDescription": "Você bloqueou este usuário. Você não poderá enviar mensagens até desbloqueá-lo.", "relayResolutionTitle": "Configuração de relay", "relayResolutionDescription": "Não conseguimos encontrar as suas listas de relays na rede. Pode fornecer um relay onde as suas listas estejam publicadas ou utilizar os nossos relays predefinidos para começar.", "relayResolutionUseDefaults": "Usar relays predefinidos", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 4bfcc62e8..41bf159a2 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -295,6 +295,13 @@ "failedToUnblockUser": "Не удалось разблокировать пользователя. Пожалуйста, попробуйте ещё раз.", "userIsBlocked": "Вы заблокировали этого пользователя", "userIsBlockedDescription": "Вы не будете получать новые сообщения, пока не разблокируете пользователя.", + "blockedUsers": "Заблокированные пользователи", + "viewBlockedUsers": "Просмотреть заблокированных пользователей", + "blockedUsersDescription": "Просматривайте и управляйте людьми, которых вы заблокировали.", + "blockedUsersEmpty": "Вы пока никого не заблокировали.", + "failedToFetchBlockedUsers": "Не удалось загрузить заблокированных пользователей. Попробуйте снова.", + "blockedUserProfileTitle": "Профиль", + "blockedUserDetailDescription": "Вы заблокировали этого пользователя. Вы не сможете отправлять сообщения, пока не разблокируете его.", "relayResolutionTitle": "Настройка реле", "relayResolutionDescription": "Мы не нашли ваши списки реле в сети. Вы можете указать реле, где опубликованы ваши списки, или использовать наши стандартные реле для начала работы.", "relayResolutionUseDefaults": "Использовать стандартные реле", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index cbf7e7fc4..613baa295 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -295,6 +295,13 @@ "failedToUnblockUser": "Engel kaldırılamadı. Lütfen tekrar deneyin.", "userIsBlocked": "Bu kullanıcıyı engellediniz", "userIsBlockedDescription": "Engeli kaldırana kadar yeni mesaj almayacaksınız.", + "blockedUsers": "Engellenen kullanıcılar", + "viewBlockedUsers": "Engellenen kullanıcıları görüntüle", + "blockedUsersDescription": "Engellediğiniz kişileri görüntüleyin ve yönetin.", + "blockedUsersEmpty": "Henüz kimseyi engellemediniz.", + "failedToFetchBlockedUsers": "Engellenen kullanıcılar yüklenemedi. Lütfen tekrar deneyin.", + "blockedUserProfileTitle": "Profil", + "blockedUserDetailDescription": "Bu kullanıcıyı engellediniz. Engeli kaldırana kadar mesaj gönderemezsiniz.", "relayResolutionTitle": "Röle Ayarları", "relayResolutionDescription": "Röle listelerinizi ağda bulamadık. Listelerinizin yayınlandığı bir röle sağlayabilir veya başlamak için varsayılan rölelerimizi kullanabilirsiniz.", "relayResolutionUseDefaults": "Varsayılan röleleri kullan", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index cfaba771c..6d405aaa2 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -299,6 +299,13 @@ "failedToUnblockUser": "取消屏蔽失败。请重试。", "userIsBlocked": "您已屏蔽此用户", "userIsBlockedDescription": "在您取消屏蔽之前,您将不会收到新消息。", + "blockedUsers": "已屏蔽的用户", + "viewBlockedUsers": "查看已屏蔽的用户", + "blockedUsersDescription": "查看并管理您屏蔽的用户。", + "blockedUsersEmpty": "您还没有屏蔽任何人。", + "failedToFetchBlockedUsers": "无法加载已屏蔽的用户。请重试。", + "blockedUserProfileTitle": "个人资料", + "blockedUserDetailDescription": "您已屏蔽此用户。在取消屏蔽之前,您无法发送消息。", "addToAnotherGroup": "添加到另一个群组", "relayResolutionTitle": "中继器设置", "relayResolutionDescription": "我们无法在网络上找到您的中继器列表。您可以提供一个已发布这些列表的中继器地址,或者使用我们的默认中继器开始使用。", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 036b6f68f..6de5c1825 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -299,6 +299,13 @@ "failedToUnblockUser": "解除封鎖失敗。請再試一次。", "userIsBlocked": "您已封鎖此使用者", "userIsBlockedDescription": "解除封鎖前,您不會收到對方的新訊息。", + "blockedUsers": "已封鎖的使用者", + "viewBlockedUsers": "查看已封鎖的使用者", + "blockedUsersDescription": "查看並管理您封鎖的對象。", + "blockedUsersEmpty": "您尚未封鎖任何人。", + "failedToFetchBlockedUsers": "無法載入已封鎖的使用者。請再試一次。", + "blockedUserProfileTitle": "個人資料", + "blockedUserDetailDescription": "您已封鎖此使用者。在解除封鎖之前,您將無法傳送訊息。", "addToAnotherGroup": "加入另一個群組", "relayResolutionTitle": "中繼站設定", "relayResolutionDescription": "我們無法在網路上找到您的中繼站清單。您可以提供已發布這些清單的中繼站地址,或使用我們的預設中繼站開始使用。", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 6a745c163..ea6396637 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -1905,6 +1905,48 @@ abstract class AppLocalizations { /// **'You won\'t receive new messages until you unblock them.'** String get userIsBlockedDescription; + /// Section label and screen title for the list of users the account has blocked + /// + /// In en, this message translates to: + /// **'Blocked users'** + String get blockedUsers; + + /// Button label that opens the blocked users management screen + /// + /// In en, this message translates to: + /// **'View blocked users'** + String get viewBlockedUsers; + + /// Helper text under the View blocked users button on the privacy & security screen + /// + /// In en, this message translates to: + /// **'View and manage people you\'ve blocked.'** + String get blockedUsersDescription; + + /// Empty state message shown when the blocked users list is empty + /// + /// In en, this message translates to: + /// **'You haven\'t blocked anyone yet.'** + String get blockedUsersEmpty; + + /// Error message shown when the blocked users list cannot be loaded + /// + /// In en, this message translates to: + /// **'Failed to load blocked users. Please try again.'** + String get failedToFetchBlockedUsers; + + /// Title for the blocked user profile detail screen + /// + /// In en, this message translates to: + /// **'Profile'** + String get blockedUserProfileTitle; + + /// Description shown in the blocked user profile screen banner + /// + /// In en, this message translates to: + /// **'You\'ve blocked this user. You won\'t be able to send messages until you unblock them.'** + String get blockedUserDetailDescription; + /// Action label to add this user to another group /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index d6cb9b54b..9dc76c493 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -1061,6 +1061,29 @@ class AppLocalizationsDe extends AppLocalizations { String get userIsBlockedDescription => 'Du erhältst keine neuen Nachrichten, bis du die Blockierung aufhebst.'; + @override + String get blockedUsers => 'Blockierte Nutzer'; + + @override + String get viewBlockedUsers => 'Blockierte Nutzer anzeigen'; + + @override + String get blockedUsersDescription => 'Personen, die du blockiert hast, ansehen und verwalten.'; + + @override + String get blockedUsersEmpty => 'Du hast noch niemanden blockiert.'; + + @override + String get failedToFetchBlockedUsers => + 'Blockierte Nutzer konnten nicht geladen werden. Bitte erneut versuchen.'; + + @override + String get blockedUserProfileTitle => 'Profil'; + + @override + String get blockedUserDetailDescription => + 'Du hast diesen Nutzer blockiert. Du kannst keine Nachrichten senden, bis die Blockierung aufgehoben ist.'; + @override String get addToAnotherGroup => 'Zu einer anderen Gruppe hinzufügen'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 1aeeb010b..e64f08c8c 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -1030,6 +1030,28 @@ class AppLocalizationsEn extends AppLocalizations { @override String get userIsBlockedDescription => 'You won\'t receive new messages until you unblock them.'; + @override + String get blockedUsers => 'Blocked users'; + + @override + String get viewBlockedUsers => 'View blocked users'; + + @override + String get blockedUsersDescription => 'View and manage people you\'ve blocked.'; + + @override + String get blockedUsersEmpty => 'You haven\'t blocked anyone yet.'; + + @override + String get failedToFetchBlockedUsers => 'Failed to load blocked users. Please try again.'; + + @override + String get blockedUserProfileTitle => 'Profile'; + + @override + String get blockedUserDetailDescription => + 'You\'ve blocked this user. You won\'t be able to send messages until you unblock them.'; + @override String get addToAnotherGroup => 'Add to another group'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index cce686b00..213ccce17 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -1045,6 +1045,29 @@ class AppLocalizationsEs extends AppLocalizations { @override String get userIsBlockedDescription => 'No recibirás nuevos mensajes hasta que lo desbloquees.'; + @override + String get blockedUsers => 'Usuarios bloqueados'; + + @override + String get viewBlockedUsers => 'Ver usuarios bloqueados'; + + @override + String get blockedUsersDescription => 'Ver y gestionar las personas que has bloqueado.'; + + @override + String get blockedUsersEmpty => 'Aún no has bloqueado a nadie.'; + + @override + String get failedToFetchBlockedUsers => + 'No se pudieron cargar los usuarios bloqueados. Inténtalo de nuevo.'; + + @override + String get blockedUserProfileTitle => 'Perfil'; + + @override + String get blockedUserDetailDescription => + 'Has bloqueado a este usuario. No podrás enviar mensajes hasta que lo desbloquees.'; + @override String get addToAnotherGroup => 'Añadir a otro grupo'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 33faf5b5c..533247557 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -1044,6 +1044,29 @@ class AppLocalizationsFr extends AppLocalizations { String get userIsBlockedDescription => 'Vous ne recevrez pas de nouveaux messages tant que vous ne le débloquez pas.'; + @override + String get blockedUsers => 'Utilisateurs bloqués'; + + @override + String get viewBlockedUsers => 'Voir les utilisateurs bloqués'; + + @override + String get blockedUsersDescription => 'Voir et gérer les personnes que vous avez bloquées.'; + + @override + String get blockedUsersEmpty => 'Vous n’avez bloqué personne pour l’instant.'; + + @override + String get failedToFetchBlockedUsers => + 'Impossible de charger les utilisateurs bloqués. Veuillez réessayer.'; + + @override + String get blockedUserProfileTitle => 'Profil'; + + @override + String get blockedUserDetailDescription => + 'Vous avez bloqué cet utilisateur. Vous ne pourrez pas envoyer de messages avant de le débloquer.'; + @override String get addToAnotherGroup => 'Ajouter à un autre groupe'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 19174c815..74044a3ee 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -1034,6 +1034,28 @@ class AppLocalizationsIt extends AppLocalizations { @override String get userIsBlockedDescription => 'Non riceverai nuovi messaggi finché non lo sblocchi.'; + @override + String get blockedUsers => 'Utenti bloccati'; + + @override + String get viewBlockedUsers => 'Vedi utenti bloccati'; + + @override + String get blockedUsersDescription => 'Visualizza e gestisci le persone che hai bloccato.'; + + @override + String get blockedUsersEmpty => 'Non hai ancora bloccato nessuno.'; + + @override + String get failedToFetchBlockedUsers => 'Impossibile caricare gli utenti bloccati. Riprova.'; + + @override + String get blockedUserProfileTitle => 'Profilo'; + + @override + String get blockedUserDetailDescription => + 'Hai bloccato questo utente. Non potrai inviare messaggi finché non lo sblocchi.'; + @override String get addToAnotherGroup => 'Aggiungi a un altro gruppo'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index d274d3b55..dda62785e 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -1042,6 +1042,29 @@ class AppLocalizationsPt extends AppLocalizations { String get userIsBlockedDescription => 'Você não receberá novas mensagens até desbloquear este usuário.'; + @override + String get blockedUsers => 'Usuários bloqueados'; + + @override + String get viewBlockedUsers => 'Ver usuários bloqueados'; + + @override + String get blockedUsersDescription => 'Veja e gerencie as pessoas que você bloqueou.'; + + @override + String get blockedUsersEmpty => 'Você ainda não bloqueou ninguém.'; + + @override + String get failedToFetchBlockedUsers => + 'Não foi possível carregar os usuários bloqueados. Tente novamente.'; + + @override + String get blockedUserProfileTitle => 'Perfil'; + + @override + String get blockedUserDetailDescription => + 'Você bloqueou este usuário. Você não poderá enviar mensagens até desbloqueá-lo.'; + @override String get addToAnotherGroup => 'Adicionar a outro grupo'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index d3092f045..dd8e0eab3 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -1060,6 +1060,30 @@ class AppLocalizationsRu extends AppLocalizations { String get userIsBlockedDescription => 'Вы не будете получать новые сообщения, пока не разблокируете пользователя.'; + @override + String get blockedUsers => 'Заблокированные пользователи'; + + @override + String get viewBlockedUsers => 'Просмотреть заблокированных пользователей'; + + @override + String get blockedUsersDescription => + 'Просматривайте и управляйте людьми, которых вы заблокировали.'; + + @override + String get blockedUsersEmpty => 'Вы пока никого не заблокировали.'; + + @override + String get failedToFetchBlockedUsers => + 'Не удалось загрузить заблокированных пользователей. Попробуйте снова.'; + + @override + String get blockedUserProfileTitle => 'Профиль'; + + @override + String get blockedUserDetailDescription => + 'Вы заблокировали этого пользователя. Вы не сможете отправлять сообщения, пока не разблокируете его.'; + @override String get addToAnotherGroup => 'Добавить в другую группу'; diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart index 8b8f9c4e9..d98683173 100644 --- a/lib/l10n/generated/app_localizations_tr.dart +++ b/lib/l10n/generated/app_localizations_tr.dart @@ -1032,6 +1032,29 @@ class AppLocalizationsTr extends AppLocalizations { @override String get userIsBlockedDescription => 'Engeli kaldırana kadar yeni mesaj almayacaksınız.'; + @override + String get blockedUsers => 'Engellenen kullanıcılar'; + + @override + String get viewBlockedUsers => 'Engellenen kullanıcıları görüntüle'; + + @override + String get blockedUsersDescription => 'Engellediğiniz kişileri görüntüleyin ve yönetin.'; + + @override + String get blockedUsersEmpty => 'Henüz kimseyi engellemediniz.'; + + @override + String get failedToFetchBlockedUsers => + 'Engellenen kullanıcılar yüklenemedi. Lütfen tekrar deneyin.'; + + @override + String get blockedUserProfileTitle => 'Profil'; + + @override + String get blockedUserDetailDescription => + 'Bu kullanıcıyı engellediniz. Engeli kaldırana kadar mesaj gönderemezsiniz.'; + @override String get addToAnotherGroup => 'Başka bir gruba ekle'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index d043fb9bb..8783d5114 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -1002,6 +1002,27 @@ class AppLocalizationsZh extends AppLocalizations { @override String get userIsBlockedDescription => '在您取消屏蔽之前,您将不会收到新消息。'; + @override + String get blockedUsers => '已屏蔽的用户'; + + @override + String get viewBlockedUsers => '查看已屏蔽的用户'; + + @override + String get blockedUsersDescription => '查看并管理您屏蔽的用户。'; + + @override + String get blockedUsersEmpty => '您还没有屏蔽任何人。'; + + @override + String get failedToFetchBlockedUsers => '无法加载已屏蔽的用户。请重试。'; + + @override + String get blockedUserProfileTitle => '个人资料'; + + @override + String get blockedUserDetailDescription => '您已屏蔽此用户。在取消屏蔽之前,您无法发送消息。'; + @override String get addToAnotherGroup => '添加到另一个群组'; @@ -2429,6 +2450,27 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get userIsBlockedDescription => '解除封鎖前,您不會收到對方的新訊息。'; + @override + String get blockedUsers => '已封鎖的使用者'; + + @override + String get viewBlockedUsers => '查看已封鎖的使用者'; + + @override + String get blockedUsersDescription => '查看並管理您封鎖的對象。'; + + @override + String get blockedUsersEmpty => '您尚未封鎖任何人。'; + + @override + String get failedToFetchBlockedUsers => '無法載入已封鎖的使用者。請再試一次。'; + + @override + String get blockedUserProfileTitle => '個人資料'; + + @override + String get blockedUserDetailDescription => '您已封鎖此使用者。在解除封鎖之前,您將無法傳送訊息。'; + @override String get addToAnotherGroup => '加入另一個群組'; diff --git a/lib/routes.dart b/lib/routes.dart index 44509b74b..2c4647468 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -14,6 +14,8 @@ import 'package:whitenoise/screens/add_relay_screen.dart' show AddRelayScreen; import 'package:whitenoise/screens/add_to_group_screen.dart' show AddToGroupScreen; import 'package:whitenoise/screens/app_logs_screen.dart' show AppLogsScreen; import 'package:whitenoise/screens/appearance_screen.dart' show AppearanceScreen; +import 'package:whitenoise/screens/blocked_user_screen.dart' show BlockedUserScreen; +import 'package:whitenoise/screens/blocked_users_screen.dart' show BlockedUsersScreen; import 'package:whitenoise/screens/chat_info_screen.dart' show ChatInfoScreen; import 'package:whitenoise/screens/chat_invite_screen.dart' show ChatInviteScreen; import 'package:whitenoise/screens/chat_list_screen.dart' show ChatListScreen; @@ -79,6 +81,8 @@ abstract final class Routes { static const _appearance = '/appearance'; static const _notificationSettings = '/notification-settings'; static const _privacySecurity = '/privacy-security'; + static const _blockedUsers = '/blocked-users'; + static const _blockedUser = '/blocked-users/:userPubkey'; static const _wip = '/wip'; static const _developerSettings = '/developer-settings'; static const _keyPackageManagement = '/key-package-management'; @@ -209,6 +213,23 @@ abstract final class Routes { child: const PrivacySecurityScreen(), ), ), + GoRoute( + name: 'blockedUsers', + path: _blockedUsers, + pageBuilder: (context, state) => _navigationTransition( + state: state, + child: const BlockedUsersScreen(), + ), + ), + GoRoute( + name: 'blockedUser', + path: _blockedUser, + pageBuilder: (context, state) => _navigationTransition( + state: state, + child: BlockedUserScreen(userPubkey: state.pathParameters['userPubkey']!), + opaque: false, + ), + ), GoRoute( path: _reportBug, @@ -566,6 +587,16 @@ abstract final class Routes { GoRouter.of(context).push(_privacySecurity); } + static Future pushToBlockedUsers(BuildContext context) { + return GoRouter.of(context).pushNamed('blockedUsers'); + } + + static Future pushToBlockedUser(BuildContext context, String userPubkey) { + return GoRouter.of( + context, + ).pushNamed('blockedUser', pathParameters: {'userPubkey': userPubkey}); + } + static void pushToDeveloperSettings(BuildContext context) { GoRouter.of(context).push(_developerSettings); } diff --git a/lib/screens/blocked_user_screen.dart b/lib/screens/blocked_user_screen.dart new file mode 100644 index 000000000..8a70775a2 --- /dev/null +++ b/lib/screens/blocked_user_screen.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:whitenoise/hooks/use_block_actions.dart'; +import 'package:whitenoise/hooks/use_system_notice.dart'; +import 'package:whitenoise/hooks/use_user_metadata.dart'; +import 'package:whitenoise/l10n/l10n.dart'; +import 'package:whitenoise/providers/account_pubkey_provider.dart'; +import 'package:whitenoise/routes.dart'; +import 'package:whitenoise/theme.dart'; +import 'package:whitenoise/utils/avatar_color.dart'; +import 'package:whitenoise/utils/metadata.dart' show presentName; +import 'package:whitenoise/widgets/wn_button.dart'; +import 'package:whitenoise/widgets/wn_chat_info_profile_card.dart'; +import 'package:whitenoise/widgets/wn_icon.dart'; +import 'package:whitenoise/widgets/wn_icon_button.dart'; +import 'package:whitenoise/widgets/wn_overlay.dart'; +import 'package:whitenoise/widgets/wn_slate.dart'; +import 'package:whitenoise/widgets/wn_slate_navigation_header.dart'; +import 'package:whitenoise/widgets/wn_system_notice.dart'; + +class BlockedUserScreen extends HookConsumerWidget { + const BlockedUserScreen({super.key, required this.userPubkey}); + + final String userPubkey; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colors = context.colors; + final typography = context.typographyScaled; + final accountPubkey = ref.watch(accountPubkeyProvider); + final metadataSnapshot = useUserMetadata(context, userPubkey); + final metadata = metadataSnapshot.data; + final blockState = useBlockActions( + accountPubkey: accountPubkey, + userPubkey: userPubkey, + ); + final systemNotice = useSystemNotice(); + final isBannerCollapsed = useState(false); + + Future handleUnblock() async { + try { + await blockState.toggleBlock(); + if (!context.mounted) return; + Routes.goBack(context); + } catch (_) { + if (context.mounted) { + systemNotice.showErrorNotice(context.l10n.failedToUnblockUser); + } + } + } + + return Scaffold( + backgroundColor: Colors.transparent, + body: Stack( + children: [ + const WnOverlay(variant: WnOverlayVariant.light), + SafeArea( + child: Align( + alignment: Alignment.topCenter, + child: WnSlate( + shrinkWrapContent: true, + header: WnSlateNavigationHeader( + title: context.l10n.blockedUserProfileTitle, + onNavigate: () => Routes.goBack(context), + ), + systemNotice: systemNotice.noticeMessage != null + ? WnSystemNotice( + key: ValueKey(systemNotice.noticeMessage), + title: systemNotice.noticeMessage!, + type: systemNotice.noticeType, + variant: WnSystemNoticeVariant.dismissible, + onDismiss: systemNotice.dismissNotice, + ) + : null, + child: Padding( + padding: EdgeInsets.fromLTRB(16.w, 0, 16.w, 16.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Gap(8.h), + WnChatInfoProfileCard( + userPubkey: userPubkey, + displayName: presentName(metadata), + pictureUrl: metadata?.picture, + avatarColor: AvatarColor.fromPubkey(userPubkey), + onPublicKeyCopied: () => systemNotice.showSuccessNotice( + context.l10n.publicKeyCopied, + ), + onPublicKeyCopyError: () => systemNotice.showErrorNotice( + context.l10n.publicKeyCopyError, + ), + ), + Gap(16.h), + Container( + key: const Key('blocked_user_detail_card'), + decoration: BoxDecoration( + color: colors.fillSecondary, + borderRadius: BorderRadius.circular(8.r), + ), + padding: EdgeInsets.fromLTRB(16.w, 8.h, 8.w, 16.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + key: const Key('blocked_user_detail_header'), + children: [ + Expanded( + child: Text( + context.l10n.userIsBlocked, + style: typography.semiBold16.copyWith( + color: colors.backgroundContentPrimary, + ), + ), + ), + WnIconButton( + key: const Key('blocked_user_detail_chevron'), + icon: isBannerCollapsed.value + ? WnIcons.chevronDown + : WnIcons.chevronUp, + size: WnIconButtonSize.size36, + onPressed: () => + isBannerCollapsed.value = !isBannerCollapsed.value, + ), + ], + ), + if (!isBannerCollapsed.value) ...[ + Padding( + padding: EdgeInsets.only(right: 8.w), + child: Text( + context.l10n.blockedUserDetailDescription, + key: const Key('blocked_user_detail_description'), + style: typography.medium14.copyWith( + color: colors.backgroundContentSecondary, + ), + ), + ), + Gap(16.h), + Padding( + padding: EdgeInsets.only(right: 8.w), + child: WnButton( + key: const Key('blocked_user_unblock_button'), + text: context.l10n.unblockUser, + type: WnButtonType.overlay, + size: WnButtonSize.medium, + loading: blockState.isActionLoading, + disabled: blockState.isLoading, + trailingIcon: WnIcons.userCheck, + onPressed: handleUnblock, + ), + ), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/blocked_users_screen.dart b/lib/screens/blocked_users_screen.dart new file mode 100644 index 000000000..23aabcd1a --- /dev/null +++ b/lib/screens/blocked_users_screen.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:whitenoise/hooks/use_blocked_pubkeys.dart'; +import 'package:whitenoise/hooks/use_route_refresh.dart'; +import 'package:whitenoise/hooks/use_user_metadata.dart'; +import 'package:whitenoise/l10n/l10n.dart'; +import 'package:whitenoise/providers/account_pubkey_provider.dart'; +import 'package:whitenoise/routes.dart'; +import 'package:whitenoise/theme.dart'; +import 'package:whitenoise/utils/avatar_color.dart'; +import 'package:whitenoise/utils/formatting.dart'; +import 'package:whitenoise/utils/metadata.dart' show presentName; +import 'package:whitenoise/widgets/wn_slate.dart'; +import 'package:whitenoise/widgets/wn_slate_navigation_header.dart'; +import 'package:whitenoise/widgets/wn_user_item.dart'; + +class BlockedUsersScreen extends HookConsumerWidget { + const BlockedUsersScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colors = context.colors; + final typography = context.typographyScaled; + final accountPubkey = ref.watch(accountPubkeyProvider); + final blocked = useBlockedPubkeys(accountPubkey); + + useRouteRefresh(context, blocked.refresh); + + final sortedPubkeys = blocked.blockedPubkeys.toList()..sort(); + + Widget body; + if (blocked.isLoading) { + body = Center( + key: const Key('blocked_users_loading'), + child: CircularProgressIndicator( + color: colors.backgroundContentPrimary, + ), + ); + } else if (blocked.error != null) { + body = Center( + child: Text( + context.l10n.failedToFetchBlockedUsers, + key: const Key('blocked_users_error'), + style: typography.medium16.copyWith( + color: colors.backgroundContentSecondary, + ), + textAlign: TextAlign.center, + ), + ); + } else if (sortedPubkeys.isEmpty) { + body = Center( + child: Text( + context.l10n.blockedUsersEmpty, + key: const Key('blocked_users_empty'), + style: typography.medium16.copyWith( + color: colors.backgroundContentSecondary, + ), + textAlign: TextAlign.center, + ), + ); + } else { + body = ListView.separated( + key: const Key('blocked_users_list'), + padding: EdgeInsets.zero, + itemCount: sortedPubkeys.length, + separatorBuilder: (context, index) => Gap(8.h), + itemBuilder: (context, index) => _BlockedUserTile( + pubkey: sortedPubkeys[index], + onTap: () => Routes.pushToBlockedUser(context, sortedPubkeys[index]), + ), + ); + } + + return Scaffold( + backgroundColor: colors.backgroundPrimary, + body: SafeArea( + child: WnSlate( + tag: 'blocked-users-list-slate', + header: WnSlateNavigationHeader( + title: context.l10n.blockedUsers, + onNavigate: () => Routes.goBack(context), + ), + child: Padding( + padding: EdgeInsets.fromLTRB(14.w, 0, 14.w, 14.h), + child: body, + ), + ), + ), + ); + } +} + +class _BlockedUserTile extends HookWidget { + const _BlockedUserTile({ + required this.pubkey, + required this.onTap, + }); + + final String pubkey; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final metadataSnapshot = useUserMetadata(context, pubkey); + final metadata = metadataSnapshot.data; + final displayName = presentName(metadata) ?? pubkey.substring(0, 8); + final npub = npubFromHex(pubkey); + + return WnUserItem( + key: Key('blocked_user_tile_$pubkey'), + displayName: displayName, + npub: npub, + pictureUrl: metadata?.picture, + avatarColor: AvatarColor.fromPubkey(pubkey), + size: WnUserItemSize.medium, + onTap: onTap, + ); + } +} diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index fe71fa333..1ca06d8c3 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -160,9 +160,9 @@ class ChatScreen extends HookConsumerWidget { final inputAreaHeight = useState(0.0); useEffect(() { - if (isRemovedFromGroup) inputAreaHeight.value = 0; + if (isRemovedFromGroup || isBlocked) inputAreaHeight.value = 0; return null; - }, [isRemovedFromGroup]); + }, [isRemovedFromGroup, isBlocked]); final search = useMessageSearch( pubkey: pubkey, @@ -702,7 +702,7 @@ class ChatScreen extends HookConsumerWidget { ], ), ), - if (!isRemovedFromGroup && !isSearchActive.value) + if (!isRemovedFromGroup && !isBlocked && !isSearchActive.value) Positioned( bottom: 0, left: 0, diff --git a/lib/screens/privacy_security_screen.dart b/lib/screens/privacy_security_screen.dart index 7aefbb6f6..a27e1215d 100644 --- a/lib/screens/privacy_security_screen.dart +++ b/lib/screens/privacy_security_screen.dart @@ -99,6 +99,32 @@ class PrivacySecurityScreen extends HookConsumerWidget { color: colors.backgroundContentSecondary, ), ), + SizedBox(height: 24.h), + Text( + context.l10n.blockedUsers, + style: typography.semiBold16.copyWith( + color: colors.backgroundContentSecondary, + ), + ), + SizedBox(height: 8.h), + SizedBox( + width: double.infinity, + child: WnButton( + key: const Key('view_blocked_users_button'), + text: context.l10n.viewBlockedUsers, + onPressed: () => Routes.pushToBlockedUsers(context), + type: WnButtonType.outline, + size: WnButtonSize.medium, + trailingIcon: WnIcons.user, + ), + ), + SizedBox(height: 8.h), + Text( + context.l10n.blockedUsersDescription, + style: typography.medium12.copyWith( + color: colors.backgroundContentSecondary, + ), + ), ], ), ), diff --git a/test/screens/blocked_user_screen_test.dart b/test/screens/blocked_user_screen_test.dart new file mode 100644 index 000000000..ca46b6a0c --- /dev/null +++ b/test/screens/blocked_user_screen_test.dart @@ -0,0 +1,241 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart' show AsyncData; +import 'package:flutter_test/flutter_test.dart'; +import 'package:whitenoise/providers/auth_provider.dart'; +import 'package:whitenoise/routes.dart'; +import 'package:whitenoise/screens/blocked_user_screen.dart'; +import 'package:whitenoise/src/rust/api/metadata.dart'; +import 'package:whitenoise/src/rust/frb_generated.dart'; +import 'package:whitenoise/widgets/wn_avatar.dart'; +import 'package:whitenoise/widgets/wn_button.dart'; +import 'package:whitenoise/widgets/wn_overlay.dart'; + +import '../mocks/mock_clipboard.dart' show clearClipboardMock, mockClipboard, mockClipboardFailing; +import '../mocks/mock_wn_api.dart'; +import '../test_helpers.dart'; + +const _testPubkey = testPubkeyA; +const _blockedPubkey = testPubkeyB; + +class _MockApi extends MockWnApi { + Completer? unblockCompleter; + Exception? unblockError; + final unblockCalls = <({String account, String target})>[]; + + @override + Future crateApiMuteListUnblockUser({ + required String accountPubkey, + required String targetPubkey, + }) async { + unblockCalls.add((account: accountPubkey, target: targetPubkey)); + if (unblockCompleter != null) await unblockCompleter!.future; + if (unblockError != null) throw unblockError!; + await super.crateApiMuteListUnblockUser( + accountPubkey: accountPubkey, + targetPubkey: targetPubkey, + ); + } + + @override + void reset() { + super.reset(); + unblockCompleter = null; + unblockError = null; + unblockCalls.clear(); + } +} + +class _MockAuthNotifier extends AuthNotifier { + @override + Future build() async { + state = const AsyncData(_testPubkey); + return _testPubkey; + } +} + +final _api = _MockApi(); + +void main() { + setUpAll(() => RustLib.initMock(api: _api)); + setUp(() { + _api.reset(); + _api.blockedPubkeys.add(_blockedPubkey); + }); + + Future pumpBlockedUserScreen(WidgetTester tester) async { + await mountTestApp( + tester, + overrides: [authProvider.overrideWith(() => _MockAuthNotifier())], + ); + await tester.pumpAndSettle(); + Routes.pushToBlockedUser( + tester.element(find.byType(Scaffold)), + _blockedPubkey, + ); + await tester.pumpAndSettle(); + } + + group('BlockedUserScreen', () { + testWidgets('displays Profile header title', (tester) async { + await pumpBlockedUserScreen(tester); + expect(find.text('Profile'), findsOneWidget); + }); + + testWidgets('uses light overlay variant so the list shows blurred behind', (tester) async { + await pumpBlockedUserScreen(tester); + + final overlay = tester.widget(find.byType(WnOverlay)); + expect(overlay.variant, WnOverlayVariant.light); + }); + + testWidgets('displays user display name when metadata has one', (tester) async { + _api.seedUserInitialSnapshot( + _blockedPubkey, + metadata: const FlutterMetadata(displayName: 'Bob', custom: {}), + ); + await pumpBlockedUserScreen(tester); + + expect(find.text('Bob'), findsOneWidget); + }); + + testWidgets('renders avatar matching the blocked user pubkey', (tester) async { + _api.seedUserInitialSnapshot( + _blockedPubkey, + metadata: const FlutterMetadata(displayName: 'Bob', custom: {}), + ); + await pumpBlockedUserScreen(tester); + + expect( + find.descendant( + of: find.byType(BlockedUserScreen), + matching: find.byType(WnAvatar), + ), + findsOneWidget, + ); + }); + + testWidgets('shows the blocked notice header and description', (tester) async { + await pumpBlockedUserScreen(tester); + + expect(find.byKey(const Key('blocked_user_detail_card')), findsOneWidget); + expect(find.byKey(const Key('blocked_user_detail_header')), findsOneWidget); + expect(find.text('You blocked this user'), findsOneWidget); + expect( + find.textContaining("You've blocked this user"), + findsOneWidget, + ); + }); + + testWidgets('notice is expanded by default (description + unblock visible)', (tester) async { + await pumpBlockedUserScreen(tester); + + expect(find.byKey(const Key('blocked_user_detail_description')), findsOneWidget); + expect(find.byKey(const Key('blocked_user_unblock_button')), findsOneWidget); + }); + + testWidgets('chevron collapses description and unblock button', (tester) async { + await pumpBlockedUserScreen(tester); + + await tester.tap(find.byKey(const Key('blocked_user_detail_chevron'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('blocked_user_detail_description')), findsNothing); + expect(find.byKey(const Key('blocked_user_unblock_button')), findsNothing); + }); + + testWidgets('chevron re-expands description after a second tap', (tester) async { + await pumpBlockedUserScreen(tester); + + await tester.tap(find.byKey(const Key('blocked_user_detail_chevron'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('blocked_user_detail_chevron'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('blocked_user_detail_description')), findsOneWidget); + expect(find.byKey(const Key('blocked_user_unblock_button')), findsOneWidget); + }); + + testWidgets('tapping unblock calls the unblock API', (tester) async { + await pumpBlockedUserScreen(tester); + + await tester.tap(find.byKey(const Key('blocked_user_unblock_button'))); + await tester.pumpAndSettle(); + + expect(_api.unblockCalls.length, 1); + expect(_api.unblockCalls[0].account, _testPubkey); + expect(_api.unblockCalls[0].target, _blockedPubkey); + }); + + testWidgets('successful unblock pops back to the previous screen', (tester) async { + await pumpBlockedUserScreen(tester); + + await tester.tap(find.byKey(const Key('blocked_user_unblock_button'))); + await tester.pumpAndSettle(); + + expect(find.byType(BlockedUserScreen), findsNothing); + }); + + testWidgets('shows loading state while unblock is in progress', (tester) async { + _api.unblockCompleter = Completer(); + await pumpBlockedUserScreen(tester); + + await tester.tap(find.byKey(const Key('blocked_user_unblock_button'))); + await tester.pump(); + + final button = tester.widget( + find.byKey(const Key('blocked_user_unblock_button')), + ); + expect(button.loading, isTrue); + + _api.unblockCompleter!.complete(); + await tester.pumpAndSettle(); + }); + + testWidgets('shows error notice when unblock fails', (tester) async { + _api.unblockError = Exception('boom'); + await pumpBlockedUserScreen(tester); + + await tester.tap(find.byKey(const Key('blocked_user_unblock_button'))); + await tester.pumpAndSettle(); + + expect( + find.text('Failed to unblock user. Please try again.'), + findsOneWidget, + ); + expect(find.byType(BlockedUserScreen), findsOneWidget); + }); + + testWidgets('shows success notice when npub is copied', (tester) async { + mockClipboard(); + addTearDown(clearClipboardMock); + await pumpBlockedUserScreen(tester); + + await tester.tap(find.byKey(const Key('copy_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Public key copied to clipboard'), findsOneWidget); + }); + + testWidgets('shows error notice when npub copy fails', (tester) async { + mockClipboardFailing(); + addTearDown(clearClipboardMock); + await pumpBlockedUserScreen(tester); + + await tester.tap(find.byKey(const Key('copy_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Failed to copy public key. Please try again.'), findsOneWidget); + }); + + testWidgets('tapping back returns to the previous screen', (tester) async { + await pumpBlockedUserScreen(tester); + + await tester.tap(find.byKey(const Key('slate_back_button'))); + await tester.pumpAndSettle(); + + expect(find.byType(BlockedUserScreen), findsNothing); + }); + }); +} diff --git a/test/screens/blocked_users_screen_test.dart b/test/screens/blocked_users_screen_test.dart new file mode 100644 index 000000000..7d7cc9e67 --- /dev/null +++ b/test/screens/blocked_users_screen_test.dart @@ -0,0 +1,184 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart' show AsyncData; +import 'package:flutter_test/flutter_test.dart'; +import 'package:whitenoise/providers/auth_provider.dart'; +import 'package:whitenoise/routes.dart'; +import 'package:whitenoise/screens/blocked_user_screen.dart'; +import 'package:whitenoise/screens/blocked_users_screen.dart'; +import 'package:whitenoise/src/rust/api/metadata.dart'; +import 'package:whitenoise/src/rust/api/mute_list.dart'; +import 'package:whitenoise/src/rust/frb_generated.dart'; + +import '../mocks/mock_wn_api.dart'; +import '../test_helpers.dart'; + +const _testPubkey = testPubkeyA; +const _blockedPubkeyB = testPubkeyB; +const _blockedPubkeyC = testPubkeyC; + +class _MockApi extends MockWnApi { + Completer>? getBlockedUsersCompleter; + Exception? getBlockedUsersError; + int getBlockedUsersCallCount = 0; + + @override + Future> crateApiMuteListGetBlockedUsers({ + required String accountPubkey, + }) async { + getBlockedUsersCallCount++; + if (getBlockedUsersCompleter != null) return getBlockedUsersCompleter!.future; + if (getBlockedUsersError != null) throw getBlockedUsersError!; + return super.crateApiMuteListGetBlockedUsers(accountPubkey: accountPubkey); + } + + @override + void reset() { + super.reset(); + getBlockedUsersCompleter = null; + getBlockedUsersError = null; + getBlockedUsersCallCount = 0; + } +} + +class _MockAuthNotifier extends AuthNotifier { + @override + Future build() async { + state = const AsyncData(_testPubkey); + return _testPubkey; + } +} + +final _api = _MockApi(); + +void main() { + setUpAll(() => RustLib.initMock(api: _api)); + setUp(() => _api.reset()); + + Future pumpBlockedUsersScreen(WidgetTester tester) async { + await mountTestApp( + tester, + overrides: [authProvider.overrideWith(() => _MockAuthNotifier())], + ); + await tester.pumpAndSettle(); + Routes.pushToBlockedUsers(tester.element(find.byType(Scaffold))); + await tester.pumpAndSettle(); + } + + group('BlockedUsersScreen', () { + testWidgets('displays Blocked users header title', (tester) async { + await pumpBlockedUsersScreen(tester); + expect(find.text('Blocked users'), findsOneWidget); + }); + + testWidgets('shows empty state when no users are blocked', (tester) async { + await pumpBlockedUsersScreen(tester); + + expect(find.byKey(const Key('blocked_users_empty')), findsOneWidget); + expect(find.text("You haven't blocked anyone yet."), findsOneWidget); + expect(find.byKey(const Key('blocked_users_list')), findsNothing); + }); + + testWidgets('shows loading indicator while fetching blocked users', (tester) async { + await mountTestApp( + tester, + overrides: [authProvider.overrideWith(() => _MockAuthNotifier())], + ); + await tester.pumpAndSettle(); + + _api.getBlockedUsersCompleter = Completer>(); + Routes.pushToBlockedUsers(tester.element(find.byType(Scaffold))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.byKey(const Key('blocked_users_loading')), findsOneWidget); + + _api.getBlockedUsersCompleter!.complete([]); + _api.getBlockedUsersCompleter = null; + await tester.pump(); + await tester.pump(); + + expect(find.byKey(const Key('blocked_users_loading')), findsNothing); + }); + + testWidgets('renders one row per blocked user', (tester) async { + _api.blockedPubkeys.addAll([_blockedPubkeyB, _blockedPubkeyC]); + _api.seedUserInitialSnapshot( + _blockedPubkeyB, + metadata: const FlutterMetadata(displayName: 'Bob', custom: {}), + ); + _api.seedUserInitialSnapshot( + _blockedPubkeyC, + metadata: const FlutterMetadata(displayName: 'Carol', custom: {}), + ); + + await pumpBlockedUsersScreen(tester); + + expect(find.byKey(const Key('blocked_users_list')), findsOneWidget); + expect(find.byKey(const Key('blocked_user_tile_$_blockedPubkeyB')), findsOneWidget); + expect(find.byKey(const Key('blocked_user_tile_$_blockedPubkeyC')), findsOneWidget); + expect(find.text('Bob'), findsOneWidget); + expect(find.text('Carol'), findsOneWidget); + }); + + testWidgets('falls back to truncated pubkey when metadata has no name', (tester) async { + _api.blockedPubkeys.add(_blockedPubkeyB); + + await pumpBlockedUsersScreen(tester); + + expect(find.textContaining(_blockedPubkeyB.substring(0, 8)), findsWidgets); + }); + + testWidgets('shows error state instead of empty state when fetch fails', (tester) async { + _api.getBlockedUsersError = Exception('boom'); + + await pumpBlockedUsersScreen(tester); + + expect(find.byKey(const Key('blocked_users_error')), findsOneWidget); + expect(find.text('Failed to load blocked users. Please try again.'), findsOneWidget); + expect(find.byKey(const Key('blocked_users_empty')), findsNothing); + }); + + testWidgets('tapping a row navigates to BlockedUserScreen', (tester) async { + _api.blockedPubkeys.add(_blockedPubkeyB); + _api.seedUserInitialSnapshot( + _blockedPubkeyB, + metadata: const FlutterMetadata(displayName: 'Bob', custom: {}), + ); + + await pumpBlockedUsersScreen(tester); + + await tester.tap(find.byKey(const Key('blocked_user_tile_$_blockedPubkeyB'))); + await tester.pumpAndSettle(); + + expect(find.byType(BlockedUserScreen), findsOneWidget); + }); + + testWidgets('returning from detail screen refreshes the blocked list', (tester) async { + _api.blockedPubkeys.add(_blockedPubkeyB); + + await pumpBlockedUsersScreen(tester); + final callsBeforeDetail = _api.getBlockedUsersCallCount; + + Routes.pushToBlockedUser( + tester.element(find.byType(BlockedUsersScreen)), + _blockedPubkeyB, + ); + await tester.pumpAndSettle(); + Routes.goBack(tester.element(find.byType(BlockedUserScreen))); + await tester.pumpAndSettle(); + + expect(_api.getBlockedUsersCallCount, greaterThan(callsBeforeDetail)); + }); + + testWidgets('tapping back navigates to previous screen', (tester) async { + await pumpBlockedUsersScreen(tester); + + await tester.tap(find.byKey(const Key('slate_back_button'))); + await tester.pumpAndSettle(); + + expect(find.byType(BlockedUsersScreen), findsNothing); + }); + }); +} diff --git a/test/screens/chat_screen_test.dart b/test/screens/chat_screen_test.dart index c35b29f3a..306136758 100644 --- a/test/screens/chat_screen_test.dart +++ b/test/screens/chat_screen_test.dart @@ -2832,6 +2832,25 @@ void main() { expect(find.text('You blocked this user'), findsOneWidget); }); + testWidgets('hides chat message input when peer is blocked', (tester) async { + await pumpChatScreen(tester); + + expect(find.byType(WnChatMessageInput), findsNothing); + }); + + testWidgets('chat input reappears after unblocking the peer', (tester) async { + await pumpChatScreen(tester); + + expect(find.byType(WnChatMessageInput), findsNothing); + + await tester.tap( + find.byKey(const Key('blocked_notice_unblock_button')), + ); + await tester.pumpAndSettle(); + + expect(find.byType(WnChatMessageInput), findsOneWidget); + }); + testWidgets('blocked notice is expanded by default', (tester) async { await pumpChatScreen(tester); diff --git a/test/screens/privacy_security_screen_test.dart b/test/screens/privacy_security_screen_test.dart index a6541419e..b8caffd1f 100644 --- a/test/screens/privacy_security_screen_test.dart +++ b/test/screens/privacy_security_screen_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:whitenoise/providers/auth_provider.dart'; import 'package:whitenoise/routes.dart'; +import 'package:whitenoise/screens/blocked_users_screen.dart'; import 'package:whitenoise/screens/chat_list_screen.dart'; import 'package:whitenoise/screens/home_screen.dart'; import 'package:whitenoise/screens/privacy_security_screen.dart'; @@ -141,5 +142,23 @@ void main() { expect(find.text('Failed to delete all data. Please try again.'), findsOneWidget); expect(find.byType(PrivacySecurityScreen), findsOneWidget); }); + + testWidgets('displays blocked users section', (tester) async { + await pumpPrivacySecurityScreen(tester); + + expect(find.text('Blocked users'), findsOneWidget); + expect(find.byKey(const Key('view_blocked_users_button')), findsOneWidget); + expect(find.text('View blocked users'), findsOneWidget); + expect(find.text("View and manage people you've blocked."), findsOneWidget); + }); + + testWidgets('tapping view blocked users navigates to BlockedUsersScreen', (tester) async { + await pumpPrivacySecurityScreen(tester); + + await tester.tap(find.byKey(const Key('view_blocked_users_button'))); + await tester.pumpAndSettle(); + + expect(find.byType(BlockedUsersScreen), findsOneWidget); + }); }); } From bb9f9357dcdec7b63c989770b615b8e0b01ee8a7 Mon Sep 17 00:00:00 2001 From: Mubarak Auwal Date: Mon, 18 May 2026 22:16:25 +0100 Subject: [PATCH 02/15] docs(changelog): link block/unblock UX entry to PR #676 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f86bec43..ad6ad5ed9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ and this project adheres to Calendar Versioning (CalVer). ### Added - Leave group from chat list for non-last admins [PR #638](https://github.com/marmot-protocol/whitenoise/pull/638) - Add archive option in chat removed warning and change wording for leave case [PR #657](https://github.com/marmot-protocol/whitenoise/pull/657) -- Hide chat composer when peer is blocked and add Blocked users management under Settings → Privacy & Security +- Hide chat composer when peer is blocked and add Blocked users management under Settings → Privacy & Security [PR #676](https://github.com/marmot-protocol/whitenoise/pull/676) ### Changed From 74b160eae30e1ff96fce81cdc9a55b0976b7c422 Mon Sep 17 00:00:00 2001 From: Mubarak Auwal Date: Mon, 18 May 2026 22:53:16 +0100 Subject: [PATCH 03/15] fix(l10n): reuse profile key instead of duplicate blockedUserProfileTitle --- lib/l10n/app_de.arb | 1 - lib/l10n/app_en.arb | 4 ---- lib/l10n/app_es.arb | 1 - lib/l10n/app_fr.arb | 1 - lib/l10n/app_it.arb | 1 - lib/l10n/app_pt.arb | 1 - lib/l10n/app_ru.arb | 1 - lib/l10n/app_tr.arb | 1 - lib/l10n/app_zh.arb | 1 - lib/l10n/app_zh_Hant.arb | 1 - lib/l10n/generated/app_localizations.dart | 6 ------ lib/l10n/generated/app_localizations_de.dart | 3 --- lib/l10n/generated/app_localizations_en.dart | 3 --- lib/l10n/generated/app_localizations_es.dart | 3 --- lib/l10n/generated/app_localizations_fr.dart | 3 --- lib/l10n/generated/app_localizations_it.dart | 3 --- lib/l10n/generated/app_localizations_pt.dart | 3 --- lib/l10n/generated/app_localizations_ru.dart | 3 --- lib/l10n/generated/app_localizations_tr.dart | 3 --- lib/l10n/generated/app_localizations_zh.dart | 6 ------ lib/screens/blocked_user_screen.dart | 2 +- 21 files changed, 1 insertion(+), 50 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 914799543..39aaa99eb 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -300,7 +300,6 @@ "blockedUsersDescription": "Personen, die du blockiert hast, ansehen und verwalten.", "blockedUsersEmpty": "Du hast noch niemanden blockiert.", "failedToFetchBlockedUsers": "Blockierte Nutzer konnten nicht geladen werden. Bitte erneut versuchen.", - "blockedUserProfileTitle": "Profil", "blockedUserDetailDescription": "Du hast diesen Nutzer blockiert. Du kannst keine Nachrichten senden, bis die Blockierung aufgehoben ist.", "relayResolutionTitle": "Relay-Einrichtung", "relayResolutionDescription": "Wir konnten Ihre Relay-Listen nicht im Netzwerk finden. Sie können ein Relay angeben, auf dem Ihre Listen veröffentlicht sind, oder unsere Standard-Relays verwenden, um loszulegen.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index dd3d73b2c..200daa0c7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1328,10 +1328,6 @@ "@failedToFetchBlockedUsers": { "description": "Error message shown when the blocked users list cannot be loaded" }, - "blockedUserProfileTitle": "Profile", - "@blockedUserProfileTitle": { - "description": "Title for the blocked user profile detail screen" - }, "blockedUserDetailDescription": "You've blocked this user. You won't be able to send messages until you unblock them.", "@blockedUserDetailDescription": { "description": "Description shown in the blocked user profile screen banner" diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 029b27da7..5e8344fcc 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -300,7 +300,6 @@ "blockedUsersDescription": "Ver y gestionar las personas que has bloqueado.", "blockedUsersEmpty": "Aún no has bloqueado a nadie.", "failedToFetchBlockedUsers": "No se pudieron cargar los usuarios bloqueados. Inténtalo de nuevo.", - "blockedUserProfileTitle": "Perfil", "blockedUserDetailDescription": "Has bloqueado a este usuario. No podrás enviar mensajes hasta que lo desbloquees.", "relayResolutionTitle": "Configuración de relé", "relayResolutionDescription": "No pudimos encontrar tus listas de relés en la red. Puedes proporcionar un relé donde estén publicadas tus listas o usar nuestros relés predeterminados para comenzar.", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index edaf597e5..198688ecc 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -300,7 +300,6 @@ "blockedUsersDescription": "Voir et gérer les personnes que vous avez bloquées.", "blockedUsersEmpty": "Vous n’avez bloqué personne pour l’instant.", "failedToFetchBlockedUsers": "Impossible de charger les utilisateurs bloqués. Veuillez réessayer.", - "blockedUserProfileTitle": "Profil", "blockedUserDetailDescription": "Vous avez bloqué cet utilisateur. Vous ne pourrez pas envoyer de messages avant de le débloquer.", "relayResolutionTitle": "Configuration du relais", "relayResolutionDescription": "Nous n'avons pas trouvé vos listes de relais sur le réseau. Vous pouvez fournir un relais où vos listes sont publiées ou utiliser nos relais par défaut pour commencer.", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index f2b8555a5..0922f2f5a 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -300,7 +300,6 @@ "blockedUsersDescription": "Visualizza e gestisci le persone che hai bloccato.", "blockedUsersEmpty": "Non hai ancora bloccato nessuno.", "failedToFetchBlockedUsers": "Impossibile caricare gli utenti bloccati. Riprova.", - "blockedUserProfileTitle": "Profilo", "blockedUserDetailDescription": "Hai bloccato questo utente. Non potrai inviare messaggi finché non lo sblocchi.", "relayResolutionTitle": "Configurazione relay", "relayResolutionDescription": "Non abbiamo trovato le tue liste di relay sulla rete. Puoi fornire un relay dove sono pubblicate le tue liste oppure utilizzare i nostri relay predefiniti per iniziare.", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 03e5edca0..d8bfd4445 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -300,7 +300,6 @@ "blockedUsersDescription": "Veja e gerencie as pessoas que você bloqueou.", "blockedUsersEmpty": "Você ainda não bloqueou ninguém.", "failedToFetchBlockedUsers": "Não foi possível carregar os usuários bloqueados. Tente novamente.", - "blockedUserProfileTitle": "Perfil", "blockedUserDetailDescription": "Você bloqueou este usuário. Você não poderá enviar mensagens até desbloqueá-lo.", "relayResolutionTitle": "Configuração de relay", "relayResolutionDescription": "Não conseguimos encontrar as suas listas de relays na rede. Pode fornecer um relay onde as suas listas estejam publicadas ou utilizar os nossos relays predefinidos para começar.", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 41bf159a2..e86dfb5f9 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -300,7 +300,6 @@ "blockedUsersDescription": "Просматривайте и управляйте людьми, которых вы заблокировали.", "blockedUsersEmpty": "Вы пока никого не заблокировали.", "failedToFetchBlockedUsers": "Не удалось загрузить заблокированных пользователей. Попробуйте снова.", - "blockedUserProfileTitle": "Профиль", "blockedUserDetailDescription": "Вы заблокировали этого пользователя. Вы не сможете отправлять сообщения, пока не разблокируете его.", "relayResolutionTitle": "Настройка реле", "relayResolutionDescription": "Мы не нашли ваши списки реле в сети. Вы можете указать реле, где опубликованы ваши списки, или использовать наши стандартные реле для начала работы.", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 613baa295..9a2f629ac 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -300,7 +300,6 @@ "blockedUsersDescription": "Engellediğiniz kişileri görüntüleyin ve yönetin.", "blockedUsersEmpty": "Henüz kimseyi engellemediniz.", "failedToFetchBlockedUsers": "Engellenen kullanıcılar yüklenemedi. Lütfen tekrar deneyin.", - "blockedUserProfileTitle": "Profil", "blockedUserDetailDescription": "Bu kullanıcıyı engellediniz. Engeli kaldırana kadar mesaj gönderemezsiniz.", "relayResolutionTitle": "Röle Ayarları", "relayResolutionDescription": "Röle listelerinizi ağda bulamadık. Listelerinizin yayınlandığı bir röle sağlayabilir veya başlamak için varsayılan rölelerimizi kullanabilirsiniz.", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 6d405aaa2..969bb89a7 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -304,7 +304,6 @@ "blockedUsersDescription": "查看并管理您屏蔽的用户。", "blockedUsersEmpty": "您还没有屏蔽任何人。", "failedToFetchBlockedUsers": "无法加载已屏蔽的用户。请重试。", - "blockedUserProfileTitle": "个人资料", "blockedUserDetailDescription": "您已屏蔽此用户。在取消屏蔽之前,您无法发送消息。", "addToAnotherGroup": "添加到另一个群组", "relayResolutionTitle": "中继器设置", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 6de5c1825..62353e541 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -304,7 +304,6 @@ "blockedUsersDescription": "查看並管理您封鎖的對象。", "blockedUsersEmpty": "您尚未封鎖任何人。", "failedToFetchBlockedUsers": "無法載入已封鎖的使用者。請再試一次。", - "blockedUserProfileTitle": "個人資料", "blockedUserDetailDescription": "您已封鎖此使用者。在解除封鎖之前,您將無法傳送訊息。", "addToAnotherGroup": "加入另一個群組", "relayResolutionTitle": "中繼站設定", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index ea6396637..590f6826f 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -1935,12 +1935,6 @@ abstract class AppLocalizations { /// **'Failed to load blocked users. Please try again.'** String get failedToFetchBlockedUsers; - /// Title for the blocked user profile detail screen - /// - /// In en, this message translates to: - /// **'Profile'** - String get blockedUserProfileTitle; - /// Description shown in the blocked user profile screen banner /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 9dc76c493..2fde5f3e5 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -1077,9 +1077,6 @@ class AppLocalizationsDe extends AppLocalizations { String get failedToFetchBlockedUsers => 'Blockierte Nutzer konnten nicht geladen werden. Bitte erneut versuchen.'; - @override - String get blockedUserProfileTitle => 'Profil'; - @override String get blockedUserDetailDescription => 'Du hast diesen Nutzer blockiert. Du kannst keine Nachrichten senden, bis die Blockierung aufgehoben ist.'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index e64f08c8c..961224043 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -1045,9 +1045,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get failedToFetchBlockedUsers => 'Failed to load blocked users. Please try again.'; - @override - String get blockedUserProfileTitle => 'Profile'; - @override String get blockedUserDetailDescription => 'You\'ve blocked this user. You won\'t be able to send messages until you unblock them.'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 213ccce17..15bdd2082 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -1061,9 +1061,6 @@ class AppLocalizationsEs extends AppLocalizations { String get failedToFetchBlockedUsers => 'No se pudieron cargar los usuarios bloqueados. Inténtalo de nuevo.'; - @override - String get blockedUserProfileTitle => 'Perfil'; - @override String get blockedUserDetailDescription => 'Has bloqueado a este usuario. No podrás enviar mensajes hasta que lo desbloquees.'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 533247557..9f201a8d7 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -1060,9 +1060,6 @@ class AppLocalizationsFr extends AppLocalizations { String get failedToFetchBlockedUsers => 'Impossible de charger les utilisateurs bloqués. Veuillez réessayer.'; - @override - String get blockedUserProfileTitle => 'Profil'; - @override String get blockedUserDetailDescription => 'Vous avez bloqué cet utilisateur. Vous ne pourrez pas envoyer de messages avant de le débloquer.'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 74044a3ee..33a986d32 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -1049,9 +1049,6 @@ class AppLocalizationsIt extends AppLocalizations { @override String get failedToFetchBlockedUsers => 'Impossibile caricare gli utenti bloccati. Riprova.'; - @override - String get blockedUserProfileTitle => 'Profilo'; - @override String get blockedUserDetailDescription => 'Hai bloccato questo utente. Non potrai inviare messaggi finché non lo sblocchi.'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index dda62785e..d84ec765e 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -1058,9 +1058,6 @@ class AppLocalizationsPt extends AppLocalizations { String get failedToFetchBlockedUsers => 'Não foi possível carregar os usuários bloqueados. Tente novamente.'; - @override - String get blockedUserProfileTitle => 'Perfil'; - @override String get blockedUserDetailDescription => 'Você bloqueou este usuário. Você não poderá enviar mensagens até desbloqueá-lo.'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index dd8e0eab3..33587da49 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -1077,9 +1077,6 @@ class AppLocalizationsRu extends AppLocalizations { String get failedToFetchBlockedUsers => 'Не удалось загрузить заблокированных пользователей. Попробуйте снова.'; - @override - String get blockedUserProfileTitle => 'Профиль'; - @override String get blockedUserDetailDescription => 'Вы заблокировали этого пользователя. Вы не сможете отправлять сообщения, пока не разблокируете его.'; diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart index d98683173..a250371ec 100644 --- a/lib/l10n/generated/app_localizations_tr.dart +++ b/lib/l10n/generated/app_localizations_tr.dart @@ -1048,9 +1048,6 @@ class AppLocalizationsTr extends AppLocalizations { String get failedToFetchBlockedUsers => 'Engellenen kullanıcılar yüklenemedi. Lütfen tekrar deneyin.'; - @override - String get blockedUserProfileTitle => 'Profil'; - @override String get blockedUserDetailDescription => 'Bu kullanıcıyı engellediniz. Engeli kaldırana kadar mesaj gönderemezsiniz.'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 8783d5114..f2a3b044c 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -1017,9 +1017,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get failedToFetchBlockedUsers => '无法加载已屏蔽的用户。请重试。'; - @override - String get blockedUserProfileTitle => '个人资料'; - @override String get blockedUserDetailDescription => '您已屏蔽此用户。在取消屏蔽之前,您无法发送消息。'; @@ -2465,9 +2462,6 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get failedToFetchBlockedUsers => '無法載入已封鎖的使用者。請再試一次。'; - @override - String get blockedUserProfileTitle => '個人資料'; - @override String get blockedUserDetailDescription => '您已封鎖此使用者。在解除封鎖之前,您將無法傳送訊息。'; diff --git a/lib/screens/blocked_user_screen.dart b/lib/screens/blocked_user_screen.dart index 8a70775a2..2a28e349f 100644 --- a/lib/screens/blocked_user_screen.dart +++ b/lib/screens/blocked_user_screen.dart @@ -63,7 +63,7 @@ class BlockedUserScreen extends HookConsumerWidget { child: WnSlate( shrinkWrapContent: true, header: WnSlateNavigationHeader( - title: context.l10n.blockedUserProfileTitle, + title: context.l10n.profile, onNavigate: () => Routes.goBack(context), ), systemNotice: systemNotice.noticeMessage != null From 9956e6e329a751be26ac5ff4e6abcc52ffee3f36 Mon Sep 17 00:00:00 2001 From: Mubarak Auwal Date: Mon, 18 May 2026 23:02:30 +0100 Subject: [PATCH 04/15] fix(blocked-user): disable unblock button while action is in flight --- lib/screens/blocked_user_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/screens/blocked_user_screen.dart b/lib/screens/blocked_user_screen.dart index 2a28e349f..4404b0801 100644 --- a/lib/screens/blocked_user_screen.dart +++ b/lib/screens/blocked_user_screen.dart @@ -148,7 +148,7 @@ class BlockedUserScreen extends HookConsumerWidget { type: WnButtonType.overlay, size: WnButtonSize.medium, loading: blockState.isActionLoading, - disabled: blockState.isLoading, + disabled: blockState.isLoading || blockState.isActionLoading, trailingIcon: WnIcons.userCheck, onPressed: handleUnblock, ), From 039d88a2138e2b400f0a0002b0fc0f98fdaeed16 Mon Sep 17 00:00:00 2001 From: Mubarak Auwal Date: Tue, 19 May 2026 19:57:35 +0100 Subject: [PATCH 05/15] fix(blocked-users): address pepina review --- lib/screens/blocked_user_screen.dart | 106 +++++++-------------- lib/screens/blocked_users_screen.dart | 30 +++++- test/routes_test.dart | 45 +++++++++ test/screens/blocked_user_screen_test.dart | 36 +++---- 4 files changed, 126 insertions(+), 91 deletions(-) diff --git a/lib/screens/blocked_user_screen.dart b/lib/screens/blocked_user_screen.dart index 4404b0801..6a1cdd505 100644 --- a/lib/screens/blocked_user_screen.dart +++ b/lib/screens/blocked_user_screen.dart @@ -15,7 +15,6 @@ import 'package:whitenoise/utils/metadata.dart' show presentName; import 'package:whitenoise/widgets/wn_button.dart'; import 'package:whitenoise/widgets/wn_chat_info_profile_card.dart'; import 'package:whitenoise/widgets/wn_icon.dart'; -import 'package:whitenoise/widgets/wn_icon_button.dart'; import 'package:whitenoise/widgets/wn_overlay.dart'; import 'package:whitenoise/widgets/wn_slate.dart'; import 'package:whitenoise/widgets/wn_slate_navigation_header.dart'; @@ -75,14 +74,13 @@ class BlockedUserScreen extends HookConsumerWidget { onDismiss: systemNotice.dismissNotice, ) : null, - child: Padding( - padding: EdgeInsets.fromLTRB(16.w, 0, 16.w, 16.h), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - Gap(8.h), - WnChatInfoProfileCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: EdgeInsets.fromLTRB(16.w, 8.h, 16.w, 16.h), + child: WnChatInfoProfileCard( userPubkey: userPubkey, displayName: presentName(metadata), pictureUrl: metadata?.picture, @@ -94,71 +92,35 @@ class BlockedUserScreen extends HookConsumerWidget { context.l10n.publicKeyCopyError, ), ), - Gap(16.h), - Container( - key: const Key('blocked_user_detail_card'), - decoration: BoxDecoration( - color: colors.fillSecondary, - borderRadius: BorderRadius.circular(8.r), - ), - padding: EdgeInsets.fromLTRB(16.w, 8.h, 8.w, 16.h), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - key: const Key('blocked_user_detail_header'), - children: [ - Expanded( - child: Text( - context.l10n.userIsBlocked, - style: typography.semiBold16.copyWith( - color: colors.backgroundContentPrimary, - ), - ), - ), - WnIconButton( - key: const Key('blocked_user_detail_chevron'), - icon: isBannerCollapsed.value - ? WnIcons.chevronDown - : WnIcons.chevronUp, - size: WnIconButtonSize.size36, - onPressed: () => - isBannerCollapsed.value = !isBannerCollapsed.value, - ), - ], - ), - if (!isBannerCollapsed.value) ...[ - Padding( - padding: EdgeInsets.only(right: 8.w), - child: Text( - context.l10n.blockedUserDetailDescription, - key: const Key('blocked_user_detail_description'), - style: typography.medium14.copyWith( - color: colors.backgroundContentSecondary, - ), - ), - ), - Gap(16.h), - Padding( - padding: EdgeInsets.only(right: 8.w), - child: WnButton( - key: const Key('blocked_user_unblock_button'), - text: context.l10n.unblockUser, - type: WnButtonType.overlay, - size: WnButtonSize.medium, - loading: blockState.isActionLoading, - disabled: blockState.isLoading || blockState.isActionLoading, - trailingIcon: WnIcons.userCheck, - onPressed: handleUnblock, - ), - ), - ], - ], + ), + WnSystemNotice( + key: const Key('blocked_user_detail_notice'), + title: context.l10n.userIsBlocked, + description: Text( + context.l10n.blockedUserDetailDescription, + style: typography.medium14.copyWith( + color: colors.backgroundContentSecondary, ), ), - ], - ), + type: WnSystemNoticeType.neutral, + variant: isBannerCollapsed.value + ? WnSystemNoticeVariant.collapsed + : WnSystemNoticeVariant.expanded, + animateEntrance: false, + onToggle: () => isBannerCollapsed.value = !isBannerCollapsed.value, + primaryAction: WnButton( + key: const Key('blocked_user_unblock_button'), + text: context.l10n.unblockUser, + type: WnButtonType.outline, + size: WnButtonSize.medium, + loading: blockState.isActionLoading, + disabled: blockState.isLoading || blockState.isActionLoading, + trailingIcon: WnIcons.userCheck, + onPressed: handleUnblock, + ), + ), + Gap(16.h), + ], ), ), ), diff --git a/lib/screens/blocked_users_screen.dart b/lib/screens/blocked_users_screen.dart index 23aabcd1a..0fc7fb93e 100644 --- a/lib/screens/blocked_users_screen.dart +++ b/lib/screens/blocked_users_screen.dart @@ -9,6 +9,7 @@ import 'package:whitenoise/hooks/use_user_metadata.dart'; import 'package:whitenoise/l10n/l10n.dart'; import 'package:whitenoise/providers/account_pubkey_provider.dart'; import 'package:whitenoise/routes.dart'; +import 'package:whitenoise/services/user_service.dart'; import 'package:whitenoise/theme.dart'; import 'package:whitenoise/utils/avatar_color.dart'; import 'package:whitenoise/utils/formatting.dart'; @@ -29,10 +30,16 @@ class BlockedUsersScreen extends HookConsumerWidget { useRouteRefresh(context, blocked.refresh); - final sortedPubkeys = blocked.blockedPubkeys.toList()..sort(); + final pubkeysKey = (blocked.blockedPubkeys.toList()..sort()).join(','); + final sortedFuture = useMemoized( + () => _sortByDisplayName(blocked.blockedPubkeys), + [pubkeysKey], + ); + final sortedSnapshot = useFuture(sortedFuture); + final sortedPubkeys = sortedSnapshot.data ?? const []; Widget body; - if (blocked.isLoading) { + if (blocked.isLoading || sortedSnapshot.connectionState != ConnectionState.done) { body = Center( key: const Key('blocked_users_loading'), child: CircularProgressIndicator( @@ -93,6 +100,25 @@ class BlockedUsersScreen extends HookConsumerWidget { } } +Future> _sortByDisplayName(Set pubkeys) async { + if (pubkeys.isEmpty) return const []; + final entries = await Future.wait( + pubkeys.map((pubkey) async { + String? name; + try { + final metadata = await UserService(pubkey).getInitialMetadata(); + name = presentName(metadata); + } catch (_) { + name = null; + } + final sortKey = (name ?? npubFromHex(pubkey) ?? pubkey).toLowerCase(); + return (pubkey: pubkey, sortKey: sortKey); + }), + ); + entries.sort((a, b) => a.sortKey.compareTo(b.sortKey)); + return entries.map((e) => e.pubkey).toList(); +} + class _BlockedUserTile extends HookWidget { const _BlockedUserTile({ required this.pubkey, diff --git a/test/routes_test.dart b/test/routes_test.dart index 65408d165..6a8488e51 100644 --- a/test/routes_test.dart +++ b/test/routes_test.dart @@ -7,6 +7,8 @@ import 'package:go_router/go_router.dart'; import 'package:whitenoise/l10n/generated/app_localizations.dart'; import 'package:whitenoise/providers/auth_provider.dart'; import 'package:whitenoise/routes.dart'; +import 'package:whitenoise/screens/blocked_user_screen.dart'; +import 'package:whitenoise/screens/blocked_users_screen.dart'; import 'package:whitenoise/screens/chat_info_screen.dart'; import 'package:whitenoise/screens/chat_invite_screen.dart'; import 'package:whitenoise/screens/chat_list_screen.dart'; @@ -706,4 +708,47 @@ void main() { expect(find.byType(LoginScreen), findsOneWidget); }); }); + + group('pushToBlockedUsers', () { + testWidgets('navigates to BlockedUsersScreen when authenticated', (tester) async { + await pumpRouter( + tester, + overrides: [ + authProvider.overrideWith(() => _AuthenticatedAuthNotifier()), + ], + ); + Routes.pushToBlockedUsers(getContext(tester)); + await tester.pumpAndSettle(); + expect(find.byType(BlockedUsersScreen), findsOneWidget); + }); + + testWidgets('redirects to LoginScreen when not authenticated', (tester) async { + await pumpRouter(tester); + Routes.pushToBlockedUsers(getContext(tester)); + await tester.pumpAndSettle(); + expect(find.byType(LoginScreen), findsOneWidget); + }); + }); + + group('pushToBlockedUser', () { + testWidgets('passes userPubkey into BlockedUserScreen', (tester) async { + await pumpRouter( + tester, + overrides: [ + authProvider.overrideWith(() => _AuthenticatedAuthNotifier()), + ], + ); + Routes.pushToBlockedUser(getContext(tester), testPubkeyB); + await tester.pumpAndSettle(); + final screen = tester.widget(find.byType(BlockedUserScreen)); + expect(screen.userPubkey, testPubkeyB); + }); + + testWidgets('redirects to LoginScreen when not authenticated', (tester) async { + await pumpRouter(tester); + Routes.pushToBlockedUser(getContext(tester), testPubkeyB); + await tester.pumpAndSettle(); + expect(find.byType(LoginScreen), findsOneWidget); + }); + }); } diff --git a/test/screens/blocked_user_screen_test.dart b/test/screens/blocked_user_screen_test.dart index ca46b6a0c..dbb95393a 100644 --- a/test/screens/blocked_user_screen_test.dart +++ b/test/screens/blocked_user_screen_test.dart @@ -11,6 +11,7 @@ import 'package:whitenoise/src/rust/frb_generated.dart'; import 'package:whitenoise/widgets/wn_avatar.dart'; import 'package:whitenoise/widgets/wn_button.dart'; import 'package:whitenoise/widgets/wn_overlay.dart'; +import 'package:whitenoise/widgets/wn_system_notice.dart'; import '../mocks/mock_clipboard.dart' show clearClipboardMock, mockClipboard, mockClipboardFailing; import '../mocks/mock_wn_api.dart'; @@ -116,11 +117,10 @@ void main() { ); }); - testWidgets('shows the blocked notice header and description', (tester) async { + testWidgets('shows the blocked notice with header and description', (tester) async { await pumpBlockedUserScreen(tester); - expect(find.byKey(const Key('blocked_user_detail_card')), findsOneWidget); - expect(find.byKey(const Key('blocked_user_detail_header')), findsOneWidget); + expect(find.byKey(const Key('blocked_user_detail_notice')), findsOneWidget); expect(find.text('You blocked this user'), findsOneWidget); expect( find.textContaining("You've blocked this user"), @@ -128,33 +128,35 @@ void main() { ); }); - testWidgets('notice is expanded by default (description + unblock visible)', (tester) async { + testWidgets('notice is expanded by default', (tester) async { await pumpBlockedUserScreen(tester); - expect(find.byKey(const Key('blocked_user_detail_description')), findsOneWidget); + final notice = tester.widget( + find.byKey(const Key('blocked_user_detail_notice')), + ); + expect(notice.variant, WnSystemNoticeVariant.expanded); expect(find.byKey(const Key('blocked_user_unblock_button')), findsOneWidget); }); - testWidgets('chevron collapses description and unblock button', (tester) async { + testWidgets('notice uses neutral type', (tester) async { await pumpBlockedUserScreen(tester); - await tester.tap(find.byKey(const Key('blocked_user_detail_chevron'))); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('blocked_user_detail_description')), findsNothing); - expect(find.byKey(const Key('blocked_user_unblock_button')), findsNothing); + final notice = tester.widget( + find.byKey(const Key('blocked_user_detail_notice')), + ); + expect(notice.type, WnSystemNoticeType.neutral); }); - testWidgets('chevron re-expands description after a second tap', (tester) async { + testWidgets('notice collapses when chevron is tapped', (tester) async { await pumpBlockedUserScreen(tester); - await tester.tap(find.byKey(const Key('blocked_user_detail_chevron'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('blocked_user_detail_chevron'))); + await tester.tap(find.byKey(const Key('systemNotice_actionIcon'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('blocked_user_detail_description')), findsOneWidget); - expect(find.byKey(const Key('blocked_user_unblock_button')), findsOneWidget); + final notice = tester.widget( + find.byKey(const Key('blocked_user_detail_notice')), + ); + expect(notice.variant, WnSystemNoticeVariant.collapsed); }); testWidgets('tapping unblock calls the unblock API', (tester) async { From b6d5fe2acb86681e2e30542085f6aac922e741cd Mon Sep 17 00:00:00 2001 From: Mubarak Auwal Date: Wed, 20 May 2026 14:05:23 +0100 Subject: [PATCH 06/15] refactor(blocked-users): morph in place on unblock and polish notice card --- lib/screens/blocked_user_screen.dart | 190 +++++++++++++++++---- lib/screens/blocked_users_screen.dart | 44 ++--- lib/theme/semantic_colors.dart | 11 ++ lib/widgets/wn_system_notice.dart | 7 + test/screens/blocked_user_screen_test.dart | 14 +- 5 files changed, 212 insertions(+), 54 deletions(-) diff --git a/lib/screens/blocked_user_screen.dart b/lib/screens/blocked_user_screen.dart index 6a1cdd505..d834c5932 100644 --- a/lib/screens/blocked_user_screen.dart +++ b/lib/screens/blocked_user_screen.dart @@ -3,7 +3,10 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:logging/logging.dart'; import 'package:whitenoise/hooks/use_block_actions.dart'; +import 'package:whitenoise/hooks/use_follow_actions.dart'; +import 'package:whitenoise/hooks/use_start_dm.dart'; import 'package:whitenoise/hooks/use_system_notice.dart'; import 'package:whitenoise/hooks/use_user_metadata.dart'; import 'package:whitenoise/l10n/l10n.dart'; @@ -20,6 +23,8 @@ import 'package:whitenoise/widgets/wn_slate.dart'; import 'package:whitenoise/widgets/wn_slate_navigation_header.dart'; import 'package:whitenoise/widgets/wn_system_notice.dart'; +final _logger = Logger('BlockedUserScreen'); + class BlockedUserScreen extends HookConsumerWidget { const BlockedUserScreen({super.key, required this.userPubkey}); @@ -36,21 +41,97 @@ class BlockedUserScreen extends HookConsumerWidget { accountPubkey: accountPubkey, userPubkey: userPubkey, ); + final followState = useFollowActions( + accountPubkey: accountPubkey, + userPubkey: userPubkey, + ); + final dmState = useStartDm( + accountPubkey: accountPubkey, + peerPubkey: userPubkey, + ); final systemNotice = useSystemNotice(); final isBannerCollapsed = useState(false); - Future handleUnblock() async { + Future handleToggleBlock({required bool wasBlocked}) async { try { await blockState.toggleBlock(); - if (!context.mounted) return; - Routes.goBack(context); } catch (_) { if (context.mounted) { - systemNotice.showErrorNotice(context.l10n.failedToUnblockUser); + systemNotice.showErrorNotice( + wasBlocked + ? context.l10n.failedToUnblockUser + : context.l10n.failedToBlockUser, + ); + } + } + } + + Future handleToggleFollow() async { + try { + await followState.toggleFollow(); + } catch (_) { + if (context.mounted) { + systemNotice.showErrorNotice(context.l10n.failedToUpdateFollow); } } } + Future handleStartChat() async { + try { + final groupId = await dmState.startDm(); + if (context.mounted) { + Routes.goToChat(context, groupId); + } + } catch (e, st) { + _logger.severe('Failed to start chat after unblock', e, st); + if (context.mounted) { + systemNotice.showErrorNotice(context.l10n.failedToStartChat); + } + } + } + + final isBlocked = blockState.isBlocked; + + Widget bottomPanel; + if (isBlocked == false) { + bottomPanel = _UnblockedActionsPanel( + followState: followState, + blockState: blockState, + dmState: dmState, + onFollow: handleToggleFollow, + onAddToGroup: () => Routes.pushToAddToGroup(context, userPubkey), + onBlock: () => handleToggleBlock(wasBlocked: false), + onSendMessage: handleStartChat, + ); + } else { + bottomPanel = WnSystemNotice( + key: const Key('blocked_user_detail_notice'), + title: context.l10n.userIsBlocked, + description: Text( + context.l10n.blockedUserDetailDescription, + style: typography.medium14.copyWith( + color: colors.backgroundContentSecondary, + ), + ), + type: WnSystemNoticeType.elevatedCard, + variant: isBannerCollapsed.value + ? WnSystemNoticeVariant.collapsed + : WnSystemNoticeVariant.expanded, + animateEntrance: false, + onToggle: () => isBannerCollapsed.value = !isBannerCollapsed.value, + primaryAction: WnButton( + key: const Key('blocked_user_unblock_button'), + text: context.l10n.unblockUser, + type: WnButtonType.overlay, + size: WnButtonSize.medium, + loading: blockState.isActionLoading, + disabled: blockState.isLoading || blockState.isActionLoading, + trailingIcon: WnIcons.userCheck, + onPressed: () => handleToggleBlock(wasBlocked: true), + ), + ); + } + return Scaffold( backgroundColor: Colors.transparent, body: Stack( @@ -93,33 +174,7 @@ class BlockedUserScreen extends HookConsumerWidget { ), ), ), - WnSystemNotice( - key: const Key('blocked_user_detail_notice'), - title: context.l10n.userIsBlocked, - description: Text( - context.l10n.blockedUserDetailDescription, - style: typography.medium14.copyWith( - color: colors.backgroundContentSecondary, - ), - ), - type: WnSystemNoticeType.neutral, - variant: isBannerCollapsed.value - ? WnSystemNoticeVariant.collapsed - : WnSystemNoticeVariant.expanded, - animateEntrance: false, - onToggle: () => isBannerCollapsed.value = !isBannerCollapsed.value, - primaryAction: WnButton( - key: const Key('blocked_user_unblock_button'), - text: context.l10n.unblockUser, - type: WnButtonType.outline, - size: WnButtonSize.medium, - loading: blockState.isActionLoading, - disabled: blockState.isLoading || blockState.isActionLoading, - trailingIcon: WnIcons.userCheck, - onPressed: handleUnblock, - ), - ), - Gap(16.h), + bottomPanel, ], ), ), @@ -130,3 +185,76 @@ class BlockedUserScreen extends HookConsumerWidget { ); } } + +class _UnblockedActionsPanel extends StatelessWidget { + const _UnblockedActionsPanel({ + required this.followState, + required this.blockState, + required this.dmState, + required this.onFollow, + required this.onAddToGroup, + required this.onBlock, + required this.onSendMessage, + }); + + final FollowActionsState followState; + final BlockActionsState blockState; + final StartDmState dmState; + final VoidCallback onFollow; + final VoidCallback onAddToGroup; + final VoidCallback onBlock; + final VoidCallback onSendMessage; + + @override + Widget build(BuildContext context) { + final isFollowing = followState.isFollowing; + return Padding( + key: const Key('blocked_user_unblocked_panel'), + padding: EdgeInsets.symmetric(horizontal: 16.w), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + WnButton( + key: const Key('blocked_user_follow_button'), + text: isFollowing == true ? context.l10n.unfollow : context.l10n.follow, + type: WnButtonType.outline, + size: WnButtonSize.medium, + trailingIcon: isFollowing == true ? WnIcons.userUnfollow : WnIcons.userFollow, + loading: followState.isLoading || followState.isActionLoading, + onPressed: onFollow, + ), + Gap(8.h), + WnButton( + key: const Key('blocked_user_add_to_group_button'), + text: context.l10n.addToGroup, + type: WnButtonType.outline, + size: WnButtonSize.medium, + trailingIcon: WnIcons.newGroupChat, + onPressed: onAddToGroup, + ), + Gap(8.h), + WnButton( + key: const Key('blocked_user_block_button'), + text: context.l10n.blockUser, + type: WnButtonType.outline, + size: WnButtonSize.medium, + trailingIcon: WnIcons.closeOutline, + loading: blockState.isActionLoading, + disabled: blockState.isLoading || blockState.isActionLoading, + onPressed: onBlock, + ), + Gap(8.h), + WnButton( + key: const Key('blocked_user_send_message_button'), + text: context.l10n.sendMessage, + size: WnButtonSize.medium, + trailingIcon: WnIcons.newChat, + loading: dmState.isLoading, + onPressed: onSendMessage, + ), + ], + ), + ); + } +} diff --git a/lib/screens/blocked_users_screen.dart b/lib/screens/blocked_users_screen.dart index 0fc7fb93e..6eb17ca3d 100644 --- a/lib/screens/blocked_users_screen.dart +++ b/lib/screens/blocked_users_screen.dart @@ -5,11 +5,11 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:whitenoise/hooks/use_blocked_pubkeys.dart'; import 'package:whitenoise/hooks/use_route_refresh.dart'; -import 'package:whitenoise/hooks/use_user_metadata.dart'; import 'package:whitenoise/l10n/l10n.dart'; import 'package:whitenoise/providers/account_pubkey_provider.dart'; import 'package:whitenoise/routes.dart'; import 'package:whitenoise/services/user_service.dart'; +import 'package:whitenoise/src/rust/api/metadata.dart'; import 'package:whitenoise/theme.dart'; import 'package:whitenoise/utils/avatar_color.dart'; import 'package:whitenoise/utils/formatting.dart'; @@ -30,13 +30,13 @@ class BlockedUsersScreen extends HookConsumerWidget { useRouteRefresh(context, blocked.refresh); - final pubkeysKey = (blocked.blockedPubkeys.toList()..sort()).join(','); + final pubkeysKey = Object.hashAllUnordered(blocked.blockedPubkeys); final sortedFuture = useMemoized( () => _sortByDisplayName(blocked.blockedPubkeys), [pubkeysKey], ); final sortedSnapshot = useFuture(sortedFuture); - final sortedPubkeys = sortedSnapshot.data ?? const []; + final sortedEntries = sortedSnapshot.data ?? const <_BlockedUserEntry>[]; Widget body; if (blocked.isLoading || sortedSnapshot.connectionState != ConnectionState.done) { @@ -57,7 +57,7 @@ class BlockedUsersScreen extends HookConsumerWidget { textAlign: TextAlign.center, ), ); - } else if (sortedPubkeys.isEmpty) { + } else if (sortedEntries.isEmpty) { body = Center( child: Text( context.l10n.blockedUsersEmpty, @@ -72,12 +72,16 @@ class BlockedUsersScreen extends HookConsumerWidget { body = ListView.separated( key: const Key('blocked_users_list'), padding: EdgeInsets.zero, - itemCount: sortedPubkeys.length, + itemCount: sortedEntries.length, separatorBuilder: (context, index) => Gap(8.h), - itemBuilder: (context, index) => _BlockedUserTile( - pubkey: sortedPubkeys[index], - onTap: () => Routes.pushToBlockedUser(context, sortedPubkeys[index]), - ), + itemBuilder: (context, index) { + final entry = sortedEntries[index]; + return _BlockedUserTile( + pubkey: entry.pubkey, + metadata: entry.metadata, + onTap: () => Routes.pushToBlockedUser(context, entry.pubkey), + ); + }, ); } @@ -100,38 +104,40 @@ class BlockedUsersScreen extends HookConsumerWidget { } } -Future> _sortByDisplayName(Set pubkeys) async { +typedef _BlockedUserEntry = ({String pubkey, FlutterMetadata? metadata}); + +Future> _sortByDisplayName(Set pubkeys) async { if (pubkeys.isEmpty) return const []; final entries = await Future.wait( pubkeys.map((pubkey) async { - String? name; + FlutterMetadata? metadata; try { - final metadata = await UserService(pubkey).getInitialMetadata(); - name = presentName(metadata); + metadata = await UserService(pubkey).getInitialMetadata(); } catch (_) { - name = null; + metadata = null; } + final name = presentName(metadata); final sortKey = (name ?? npubFromHex(pubkey) ?? pubkey).toLowerCase(); - return (pubkey: pubkey, sortKey: sortKey); + return (pubkey: pubkey, sortKey: sortKey, metadata: metadata); }), ); entries.sort((a, b) => a.sortKey.compareTo(b.sortKey)); - return entries.map((e) => e.pubkey).toList(); + return entries.map<_BlockedUserEntry>((e) => (pubkey: e.pubkey, metadata: e.metadata)).toList(); } -class _BlockedUserTile extends HookWidget { +class _BlockedUserTile extends StatelessWidget { const _BlockedUserTile({ required this.pubkey, + required this.metadata, required this.onTap, }); final String pubkey; + final FlutterMetadata? metadata; final VoidCallback onTap; @override Widget build(BuildContext context) { - final metadataSnapshot = useUserMetadata(context, pubkey); - final metadata = metadataSnapshot.data; final displayName = presentName(metadata) ?? pubkey.substring(0, 8); final npub = npubFromHex(pubkey); diff --git a/lib/theme/semantic_colors.dart b/lib/theme/semantic_colors.dart index a380bc976..a6e063c9f 100644 --- a/lib/theme/semantic_colors.dart +++ b/lib/theme/semantic_colors.dart @@ -506,6 +506,7 @@ class SemanticColors extends ThemeExtension { final Color backgroundSecondary; final Color backgroundTertiary; final Color backgroundSlate; + final Color backgroundSlateElevated; final Color backgroundMessageIncoming; final Color backgroundContentPrimary; final Color backgroundContentSecondary; @@ -560,6 +561,7 @@ class SemanticColors extends ThemeExtension { required this.backgroundSecondary, required this.backgroundTertiary, required this.backgroundSlate, + required this.backgroundSlateElevated, required this.backgroundMessageIncoming, required this.backgroundContentPrimary, required this.backgroundContentSecondary, @@ -615,6 +617,7 @@ class SemanticColors extends ThemeExtension { backgroundSecondary: _NeutralColors.neutral50, backgroundTertiary: _NeutralColors.neutral100, backgroundSlate: _NeutralColors.neutral50, + backgroundSlateElevated: _NeutralColors.neutral100, backgroundMessageIncoming: _NeutralColors.neutral100, backgroundContentPrimary: _NeutralColors.neutral950, backgroundContentSecondary: _NeutralColors.neutral500, @@ -670,6 +673,7 @@ class SemanticColors extends ThemeExtension { backgroundSecondary: _NeutralColors.neutral950, backgroundTertiary: _NeutralColors.neutral900, backgroundSlate: _NeutralColors.neutral900, + backgroundSlateElevated: _NeutralColors.neutral850, backgroundMessageIncoming: _NeutralColors.neutral800, backgroundContentPrimary: _BaseColors.white, backgroundContentSecondary: _NeutralColors.neutral400, @@ -726,6 +730,7 @@ class SemanticColors extends ThemeExtension { Color? backgroundSecondary, Color? backgroundTertiary, Color? backgroundSlate, + Color? backgroundSlateElevated, Color? backgroundMessageIncoming, Color? backgroundContentPrimary, Color? backgroundContentSecondary, @@ -780,6 +785,7 @@ class SemanticColors extends ThemeExtension { backgroundSecondary: backgroundSecondary ?? this.backgroundSecondary, backgroundTertiary: backgroundTertiary ?? this.backgroundTertiary, backgroundSlate: backgroundSlate ?? this.backgroundSlate, + backgroundSlateElevated: backgroundSlateElevated ?? this.backgroundSlateElevated, backgroundMessageIncoming: backgroundMessageIncoming ?? this.backgroundMessageIncoming, backgroundContentPrimary: backgroundContentPrimary ?? this.backgroundContentPrimary, backgroundContentSecondary: backgroundContentSecondary ?? this.backgroundContentSecondary, @@ -841,6 +847,11 @@ class SemanticColors extends ThemeExtension { backgroundSecondary: Color.lerp(backgroundSecondary, other.backgroundSecondary, t)!, backgroundTertiary: Color.lerp(backgroundTertiary, other.backgroundTertiary, t)!, backgroundSlate: Color.lerp(backgroundSlate, other.backgroundSlate, t)!, + backgroundSlateElevated: Color.lerp( + backgroundSlateElevated, + other.backgroundSlateElevated, + t, + )!, backgroundMessageIncoming: Color.lerp( backgroundMessageIncoming, other.backgroundMessageIncoming, diff --git a/lib/widgets/wn_system_notice.dart b/lib/widgets/wn_system_notice.dart index a2e2d4745..d63c8c3d9 100644 --- a/lib/widgets/wn_system_notice.dart +++ b/lib/widgets/wn_system_notice.dart @@ -9,6 +9,7 @@ import 'package:whitenoise/widgets/wn_icon.dart'; enum WnSystemNoticeType { neutral, + elevatedCard, info, success, warning, @@ -231,6 +232,12 @@ class WnSystemNotice extends HookWidget { colors.backgroundContentPrimary, null, ); + case WnSystemNoticeType.elevatedCard: + return ( + colors.backgroundSlateElevated, + colors.backgroundContentPrimary, + null, + ); case WnSystemNoticeType.info: return ( colors.intentionInfoBackground, diff --git a/test/screens/blocked_user_screen_test.dart b/test/screens/blocked_user_screen_test.dart index dbb95393a..5e8902717 100644 --- a/test/screens/blocked_user_screen_test.dart +++ b/test/screens/blocked_user_screen_test.dart @@ -138,13 +138,15 @@ void main() { expect(find.byKey(const Key('blocked_user_unblock_button')), findsOneWidget); }); - testWidgets('notice uses neutral type', (tester) async { + testWidgets('notice uses the elevatedCard type so the card stands off the slate', ( + tester, + ) async { await pumpBlockedUserScreen(tester); final notice = tester.widget( find.byKey(const Key('blocked_user_detail_notice')), ); - expect(notice.type, WnSystemNoticeType.neutral); + expect(notice.type, WnSystemNoticeType.elevatedCard); }); testWidgets('notice collapses when chevron is tapped', (tester) async { @@ -170,13 +172,17 @@ void main() { expect(_api.unblockCalls[0].target, _blockedPubkey); }); - testWidgets('successful unblock pops back to the previous screen', (tester) async { + testWidgets('successful unblock morphs the same screen into the action panel', (tester) async { await pumpBlockedUserScreen(tester); await tester.tap(find.byKey(const Key('blocked_user_unblock_button'))); await tester.pumpAndSettle(); - expect(find.byType(BlockedUserScreen), findsNothing); + expect(find.byType(BlockedUserScreen), findsOneWidget); + expect(find.byKey(const Key('blocked_user_unblocked_panel')), findsOneWidget); + expect(find.byKey(const Key('blocked_user_detail_notice')), findsNothing); + expect(find.byKey(const Key('blocked_user_send_message_button')), findsOneWidget); + expect(find.byKey(const Key('blocked_user_block_button')), findsOneWidget); }); testWidgets('shows loading state while unblock is in progress', (tester) async { From cb83aa9938b6a36c6430c165d5e40a1d251c90b2 Mon Sep 17 00:00:00 2001 From: Mubarak Auwal Date: Wed, 20 May 2026 14:11:20 +0100 Subject: [PATCH 07/15] fix(blocked-users): restore bottom padding only when unblocked panel is shown --- lib/screens/blocked_user_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/screens/blocked_user_screen.dart b/lib/screens/blocked_user_screen.dart index d834c5932..003e43760 100644 --- a/lib/screens/blocked_user_screen.dart +++ b/lib/screens/blocked_user_screen.dart @@ -210,7 +210,7 @@ class _UnblockedActionsPanel extends StatelessWidget { final isFollowing = followState.isFollowing; return Padding( key: const Key('blocked_user_unblocked_panel'), - padding: EdgeInsets.symmetric(horizontal: 16.w), + padding: EdgeInsets.fromLTRB(16.w, 0, 16.w, 16.h), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, From cf3b0fb2950f970a4fd23e1bbbda05bb82756121 Mon Sep 17 00:00:00 2001 From: Mubarak Auwal Date: Wed, 20 May 2026 14:18:33 +0100 Subject: [PATCH 08/15] style(blocked-users): apply dart format --- lib/screens/blocked_user_screen.dart | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/screens/blocked_user_screen.dart b/lib/screens/blocked_user_screen.dart index 003e43760..5a64ca350 100644 --- a/lib/screens/blocked_user_screen.dart +++ b/lib/screens/blocked_user_screen.dart @@ -58,9 +58,7 @@ class BlockedUserScreen extends HookConsumerWidget { } catch (_) { if (context.mounted) { systemNotice.showErrorNotice( - wasBlocked - ? context.l10n.failedToUnblockUser - : context.l10n.failedToBlockUser, + wasBlocked ? context.l10n.failedToUnblockUser : context.l10n.failedToBlockUser, ); } } From 27f6a2d47ff5a645974bcd02634c7bd52b2ba67b Mon Sep 17 00:00:00 2001 From: Mubarak Auwal Date: Wed, 20 May 2026 14:34:13 +0100 Subject: [PATCH 09/15] Cover unblocked action panel buttons in BlockedUserScreen tests --- test/screens/blocked_user_screen_test.dart | 96 ++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/test/screens/blocked_user_screen_test.dart b/test/screens/blocked_user_screen_test.dart index 5e8902717..3b6cee83d 100644 --- a/test/screens/blocked_user_screen_test.dart +++ b/test/screens/blocked_user_screen_test.dart @@ -23,7 +23,10 @@ const _blockedPubkey = testPubkeyB; class _MockApi extends MockWnApi { Completer? unblockCompleter; Exception? unblockError; + Exception? followError; + String? dmGroupForPeer; final unblockCalls = <({String account, String target})>[]; + final followCalls = []; @override Future crateApiMuteListUnblockUser({ @@ -39,12 +42,32 @@ class _MockApi extends MockWnApi { ); } + @override + Future crateApiAccountsFollowUser({ + required String accountPubkey, + required String userToFollowPubkey, + }) async { + followCalls.add(userToFollowPubkey); + if (followError != null) throw followError!; + } + + @override + Future crateApiAccountGroupsGetDmGroupWithPeer({ + required String accountPubkey, + required String peerPubkey, + }) async { + return dmGroupForPeer; + } + @override void reset() { super.reset(); unblockCompleter = null; unblockError = null; + followError = null; + dmGroupForPeer = null; unblockCalls.clear(); + followCalls.clear(); } } @@ -245,5 +268,78 @@ void main() { expect(find.byType(BlockedUserScreen), findsNothing); }); + + Future unblockToActionPanel(WidgetTester tester) async { + await pumpBlockedUserScreen(tester); + await tester.tap(find.byKey(const Key('blocked_user_unblock_button'))); + await tester.pumpAndSettle(); + } + + testWidgets('tapping Follow on the unblocked panel calls the follow API', (tester) async { + await unblockToActionPanel(tester); + + await tester.tap(find.byKey(const Key('blocked_user_follow_button'))); + await tester.pumpAndSettle(); + + expect(_api.followCalls, [_blockedPubkey]); + }); + + testWidgets('Follow failure surfaces an error notice', (tester) async { + _api.followError = Exception('boom'); + await unblockToActionPanel(tester); + + await tester.tap(find.byKey(const Key('blocked_user_follow_button'))); + await tester.pumpAndSettle(); + + expect( + find.text('Failed to update follow status. Please try again.'), + findsOneWidget, + ); + }); + + testWidgets('tapping Send message navigates to the chat when a DM already exists', ( + tester, + ) async { + _api.dmGroupForPeer = 'existing-group-id'; + await unblockToActionPanel(tester); + + await tester.tap(find.byKey(const Key('blocked_user_send_message_button'))); + await tester.pumpAndSettle(); + + expect(find.byType(BlockedUserScreen), findsNothing); + }); + + testWidgets('Send message failure surfaces an error notice and keeps the screen', ( + tester, + ) async { + await unblockToActionPanel(tester); + + await tester.tap(find.byKey(const Key('blocked_user_send_message_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Failed to start chat. Please try again.'), findsOneWidget); + expect(find.byType(BlockedUserScreen), findsOneWidget); + }); + + testWidgets('tapping Block on the unblocked panel re-blocks and morphs back to the notice', ( + tester, + ) async { + await unblockToActionPanel(tester); + + await tester.tap(find.byKey(const Key('blocked_user_block_button'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('blocked_user_detail_notice')), findsOneWidget); + expect(find.byKey(const Key('blocked_user_unblocked_panel')), findsNothing); + }); + + testWidgets('tapping Add to group leaves the BlockedUserScreen', (tester) async { + await unblockToActionPanel(tester); + + await tester.tap(find.byKey(const Key('blocked_user_add_to_group_button'))); + await tester.pumpAndSettle(); + + expect(find.byType(BlockedUserScreen), findsNothing); + }); }); } From 3c9c70e89ecd67085a7905c4ca62cf9828e78406 Mon Sep 17 00:00:00 2001 From: Mubarak Auwal Date: Wed, 20 May 2026 19:30:32 +0100 Subject: [PATCH 10/15] Consolidate blocked-user and start-chat screens into UserProfileScreen --- lib/routes.dart | 53 +-- lib/screens/blocked_user_screen.dart | 258 ---------- lib/screens/blocked_users_screen.dart | 6 +- lib/screens/scan_npub_screen.dart | 2 +- lib/screens/start_chat_screen.dart | 286 ----------- lib/screens/user_profile_screen.dart | 445 ++++++++++++++++++ lib/screens/user_search_screen.dart | 2 +- lib/utils/deep_links.dart | 2 +- lib/widgets/chat_message_bubble.dart | 6 +- test/routes_test.dart | 15 +- test/screens/blocked_user_screen_test.dart | 345 -------------- test/screens/blocked_users_screen_test.dart | 10 +- test/screens/scan_npub_screen_test.dart | 6 +- ...est.dart => user_profile_screen_test.dart} | 116 ++--- test/screens/user_search_screen_test.dart | 4 +- test/utils/deep_links_test.dart | 6 +- test/widgets/chat_message_bubble_test.dart | 16 +- 17 files changed, 563 insertions(+), 1015 deletions(-) delete mode 100644 lib/screens/blocked_user_screen.dart delete mode 100644 lib/screens/start_chat_screen.dart create mode 100644 lib/screens/user_profile_screen.dart delete mode 100644 test/screens/blocked_user_screen_test.dart rename test/screens/{start_chat_screen_test.dart => user_profile_screen_test.dart} (84%) diff --git a/lib/routes.dart b/lib/routes.dart index 2c4647468..9b28f706a 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -14,7 +14,6 @@ import 'package:whitenoise/screens/add_relay_screen.dart' show AddRelayScreen; import 'package:whitenoise/screens/add_to_group_screen.dart' show AddToGroupScreen; import 'package:whitenoise/screens/app_logs_screen.dart' show AppLogsScreen; import 'package:whitenoise/screens/appearance_screen.dart' show AppearanceScreen; -import 'package:whitenoise/screens/blocked_user_screen.dart' show BlockedUserScreen; import 'package:whitenoise/screens/blocked_users_screen.dart' show BlockedUsersScreen; import 'package:whitenoise/screens/chat_info_screen.dart' show ChatInfoScreen; import 'package:whitenoise/screens/chat_invite_screen.dart' show ChatInviteScreen; @@ -46,9 +45,9 @@ import 'package:whitenoise/screens/settings_screen.dart' show SettingsScreen; import 'package:whitenoise/screens/share_profile_screen.dart' show ShareProfileScreen; import 'package:whitenoise/screens/sign_out_screen.dart' show SignOutScreen; import 'package:whitenoise/screens/signup_screen.dart' show SignupScreen; -import 'package:whitenoise/screens/start_chat_screen.dart' show StartChatScreen; import 'package:whitenoise/screens/start_support_chat_screen.dart' show StartSupportChatScreen; import 'package:whitenoise/screens/switch_profile_screen.dart' show SwitchProfileScreen; +import 'package:whitenoise/screens/user_profile_screen.dart' show UserProfileScreen; import 'package:whitenoise/screens/user_search_screen.dart' show UserSearchScreen; import 'package:whitenoise/screens/user_selection_screen.dart' show UserSelectionScreen; import 'package:whitenoise/src/rust/api/users.dart' show User; @@ -82,7 +81,6 @@ abstract final class Routes { static const _notificationSettings = '/notification-settings'; static const _privacySecurity = '/privacy-security'; static const _blockedUsers = '/blocked-users'; - static const _blockedUser = '/blocked-users/:userPubkey'; static const _wip = '/wip'; static const _developerSettings = '/developer-settings'; static const _keyPackageManagement = '/key-package-management'; @@ -103,7 +101,7 @@ abstract final class Routes { static const _userSelection = '/user-selection'; static const _setUpGroup = '/set-up-group'; static const _addToGroup = '/add-to-group/:userPubkey'; - static const _startChat = '/start-chat/:userPubkey'; + static const _userProfile = '/user-profile/:userPubkey'; static const _chatInfo = '/chat-info/:mlsGroupId'; static const _inviteInfo = '/invite-info/:mlsGroupId'; static const _groupInfo = '/group-info/:groupId'; @@ -221,16 +219,6 @@ abstract final class Routes { child: const BlockedUsersScreen(), ), ), - GoRoute( - name: 'blockedUser', - path: _blockedUser, - pageBuilder: (context, state) => _navigationTransition( - state: state, - child: BlockedUserScreen(userPubkey: state.pathParameters['userPubkey']!), - opaque: false, - ), - ), - GoRoute( path: _reportBug, pageBuilder: (context, state) => _navigationTransition( @@ -401,14 +389,19 @@ abstract final class Routes { ), ), GoRoute( - name: 'startChat', - path: _startChat, - pageBuilder: (context, state) => _navigationTransition( - state: state, - child: StartChatScreen( - userPubkey: state.pathParameters['userPubkey']!, - ), - ), + name: 'userProfile', + path: _userProfile, + pageBuilder: (context, state) { + final topAligned = state.uri.queryParameters['position'] == 'top'; + return _navigationTransition( + state: state, + opaque: !topAligned, + child: UserProfileScreen( + userPubkey: state.pathParameters['userPubkey']!, + topAligned: topAligned, + ), + ); + }, ), GoRoute( name: 'chatInfo', @@ -591,12 +584,6 @@ abstract final class Routes { return GoRouter.of(context).pushNamed('blockedUsers'); } - static Future pushToBlockedUser(BuildContext context, String userPubkey) { - return GoRouter.of( - context, - ).pushNamed('blockedUser', pathParameters: {'userPubkey': userPubkey}); - } - static void pushToDeveloperSettings(BuildContext context) { GoRouter.of(context).push(_developerSettings); } @@ -729,13 +716,15 @@ abstract final class Routes { ); } - static void pushToStartChat( + static void pushToUserProfile( BuildContext context, - String userPubkey, - ) { + String userPubkey, { + bool topAligned = false, + }) { GoRouter.of(context).pushNamed( - 'startChat', + 'userProfile', pathParameters: {'userPubkey': userPubkey}, + queryParameters: topAligned ? const {'position': 'top'} : const {}, ); } diff --git a/lib/screens/blocked_user_screen.dart b/lib/screens/blocked_user_screen.dart deleted file mode 100644 index 5a64ca350..000000000 --- a/lib/screens/blocked_user_screen.dart +++ /dev/null @@ -1,258 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:gap/gap.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:logging/logging.dart'; -import 'package:whitenoise/hooks/use_block_actions.dart'; -import 'package:whitenoise/hooks/use_follow_actions.dart'; -import 'package:whitenoise/hooks/use_start_dm.dart'; -import 'package:whitenoise/hooks/use_system_notice.dart'; -import 'package:whitenoise/hooks/use_user_metadata.dart'; -import 'package:whitenoise/l10n/l10n.dart'; -import 'package:whitenoise/providers/account_pubkey_provider.dart'; -import 'package:whitenoise/routes.dart'; -import 'package:whitenoise/theme.dart'; -import 'package:whitenoise/utils/avatar_color.dart'; -import 'package:whitenoise/utils/metadata.dart' show presentName; -import 'package:whitenoise/widgets/wn_button.dart'; -import 'package:whitenoise/widgets/wn_chat_info_profile_card.dart'; -import 'package:whitenoise/widgets/wn_icon.dart'; -import 'package:whitenoise/widgets/wn_overlay.dart'; -import 'package:whitenoise/widgets/wn_slate.dart'; -import 'package:whitenoise/widgets/wn_slate_navigation_header.dart'; -import 'package:whitenoise/widgets/wn_system_notice.dart'; - -final _logger = Logger('BlockedUserScreen'); - -class BlockedUserScreen extends HookConsumerWidget { - const BlockedUserScreen({super.key, required this.userPubkey}); - - final String userPubkey; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final colors = context.colors; - final typography = context.typographyScaled; - final accountPubkey = ref.watch(accountPubkeyProvider); - final metadataSnapshot = useUserMetadata(context, userPubkey); - final metadata = metadataSnapshot.data; - final blockState = useBlockActions( - accountPubkey: accountPubkey, - userPubkey: userPubkey, - ); - final followState = useFollowActions( - accountPubkey: accountPubkey, - userPubkey: userPubkey, - ); - final dmState = useStartDm( - accountPubkey: accountPubkey, - peerPubkey: userPubkey, - ); - final systemNotice = useSystemNotice(); - final isBannerCollapsed = useState(false); - - Future handleToggleBlock({required bool wasBlocked}) async { - try { - await blockState.toggleBlock(); - } catch (_) { - if (context.mounted) { - systemNotice.showErrorNotice( - wasBlocked ? context.l10n.failedToUnblockUser : context.l10n.failedToBlockUser, - ); - } - } - } - - Future handleToggleFollow() async { - try { - await followState.toggleFollow(); - } catch (_) { - if (context.mounted) { - systemNotice.showErrorNotice(context.l10n.failedToUpdateFollow); - } - } - } - - Future handleStartChat() async { - try { - final groupId = await dmState.startDm(); - if (context.mounted) { - Routes.goToChat(context, groupId); - } - } catch (e, st) { - _logger.severe('Failed to start chat after unblock', e, st); - if (context.mounted) { - systemNotice.showErrorNotice(context.l10n.failedToStartChat); - } - } - } - - final isBlocked = blockState.isBlocked; - - Widget bottomPanel; - if (isBlocked == false) { - bottomPanel = _UnblockedActionsPanel( - followState: followState, - blockState: blockState, - dmState: dmState, - onFollow: handleToggleFollow, - onAddToGroup: () => Routes.pushToAddToGroup(context, userPubkey), - onBlock: () => handleToggleBlock(wasBlocked: false), - onSendMessage: handleStartChat, - ); - } else { - bottomPanel = WnSystemNotice( - key: const Key('blocked_user_detail_notice'), - title: context.l10n.userIsBlocked, - description: Text( - context.l10n.blockedUserDetailDescription, - style: typography.medium14.copyWith( - color: colors.backgroundContentSecondary, - ), - ), - type: WnSystemNoticeType.elevatedCard, - variant: isBannerCollapsed.value - ? WnSystemNoticeVariant.collapsed - : WnSystemNoticeVariant.expanded, - animateEntrance: false, - onToggle: () => isBannerCollapsed.value = !isBannerCollapsed.value, - primaryAction: WnButton( - key: const Key('blocked_user_unblock_button'), - text: context.l10n.unblockUser, - type: WnButtonType.overlay, - size: WnButtonSize.medium, - loading: blockState.isActionLoading, - disabled: blockState.isLoading || blockState.isActionLoading, - trailingIcon: WnIcons.userCheck, - onPressed: () => handleToggleBlock(wasBlocked: true), - ), - ); - } - - return Scaffold( - backgroundColor: Colors.transparent, - body: Stack( - children: [ - const WnOverlay(variant: WnOverlayVariant.light), - SafeArea( - child: Align( - alignment: Alignment.topCenter, - child: WnSlate( - shrinkWrapContent: true, - header: WnSlateNavigationHeader( - title: context.l10n.profile, - onNavigate: () => Routes.goBack(context), - ), - systemNotice: systemNotice.noticeMessage != null - ? WnSystemNotice( - key: ValueKey(systemNotice.noticeMessage), - title: systemNotice.noticeMessage!, - type: systemNotice.noticeType, - variant: WnSystemNoticeVariant.dismissible, - onDismiss: systemNotice.dismissNotice, - ) - : null, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: EdgeInsets.fromLTRB(16.w, 8.h, 16.w, 16.h), - child: WnChatInfoProfileCard( - userPubkey: userPubkey, - displayName: presentName(metadata), - pictureUrl: metadata?.picture, - avatarColor: AvatarColor.fromPubkey(userPubkey), - onPublicKeyCopied: () => systemNotice.showSuccessNotice( - context.l10n.publicKeyCopied, - ), - onPublicKeyCopyError: () => systemNotice.showErrorNotice( - context.l10n.publicKeyCopyError, - ), - ), - ), - bottomPanel, - ], - ), - ), - ), - ), - ], - ), - ); - } -} - -class _UnblockedActionsPanel extends StatelessWidget { - const _UnblockedActionsPanel({ - required this.followState, - required this.blockState, - required this.dmState, - required this.onFollow, - required this.onAddToGroup, - required this.onBlock, - required this.onSendMessage, - }); - - final FollowActionsState followState; - final BlockActionsState blockState; - final StartDmState dmState; - final VoidCallback onFollow; - final VoidCallback onAddToGroup; - final VoidCallback onBlock; - final VoidCallback onSendMessage; - - @override - Widget build(BuildContext context) { - final isFollowing = followState.isFollowing; - return Padding( - key: const Key('blocked_user_unblocked_panel'), - padding: EdgeInsets.fromLTRB(16.w, 0, 16.w, 16.h), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - WnButton( - key: const Key('blocked_user_follow_button'), - text: isFollowing == true ? context.l10n.unfollow : context.l10n.follow, - type: WnButtonType.outline, - size: WnButtonSize.medium, - trailingIcon: isFollowing == true ? WnIcons.userUnfollow : WnIcons.userFollow, - loading: followState.isLoading || followState.isActionLoading, - onPressed: onFollow, - ), - Gap(8.h), - WnButton( - key: const Key('blocked_user_add_to_group_button'), - text: context.l10n.addToGroup, - type: WnButtonType.outline, - size: WnButtonSize.medium, - trailingIcon: WnIcons.newGroupChat, - onPressed: onAddToGroup, - ), - Gap(8.h), - WnButton( - key: const Key('blocked_user_block_button'), - text: context.l10n.blockUser, - type: WnButtonType.outline, - size: WnButtonSize.medium, - trailingIcon: WnIcons.closeOutline, - loading: blockState.isActionLoading, - disabled: blockState.isLoading || blockState.isActionLoading, - onPressed: onBlock, - ), - Gap(8.h), - WnButton( - key: const Key('blocked_user_send_message_button'), - text: context.l10n.sendMessage, - size: WnButtonSize.medium, - trailingIcon: WnIcons.newChat, - loading: dmState.isLoading, - onPressed: onSendMessage, - ), - ], - ), - ); - } -} diff --git a/lib/screens/blocked_users_screen.dart b/lib/screens/blocked_users_screen.dart index 6eb17ca3d..b4e0b08bf 100644 --- a/lib/screens/blocked_users_screen.dart +++ b/lib/screens/blocked_users_screen.dart @@ -79,7 +79,11 @@ class BlockedUsersScreen extends HookConsumerWidget { return _BlockedUserTile( pubkey: entry.pubkey, metadata: entry.metadata, - onTap: () => Routes.pushToBlockedUser(context, entry.pubkey), + onTap: () => Routes.pushToUserProfile( + context, + entry.pubkey, + topAligned: true, + ), ); }, ); diff --git a/lib/screens/scan_npub_screen.dart b/lib/screens/scan_npub_screen.dart index e59f3d4f1..47f004e4d 100644 --- a/lib/screens/scan_npub_screen.dart +++ b/lib/screens/scan_npub_screen.dart @@ -32,7 +32,7 @@ class ScanNpubScreen extends HookWidget { final hexPubkey = hexFromNpub(value); if (hexPubkey != null) { Routes.goBack(context); - Routes.pushToStartChat(context, hexPubkey); + Routes.pushToUserProfile(context, hexPubkey); } else if (value.startsWith('npub1')) { showInvalidNpubError.value = true; } diff --git a/lib/screens/start_chat_screen.dart b/lib/screens/start_chat_screen.dart deleted file mode 100644 index 2a99b0753..000000000 --- a/lib/screens/start_chat_screen.dart +++ /dev/null @@ -1,286 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:gap/gap.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:logging/logging.dart'; -import 'package:share_plus/share_plus.dart'; -import 'package:whitenoise/hooks/use_follow_actions.dart'; -import 'package:whitenoise/hooks/use_start_dm.dart'; -import 'package:whitenoise/hooks/use_system_notice.dart'; -import 'package:whitenoise/hooks/use_user_has_key_package.dart'; -import 'package:whitenoise/hooks/use_user_metadata.dart'; -import 'package:whitenoise/l10n/l10n.dart'; -import 'package:whitenoise/providers/account_pubkey_provider.dart'; -import 'package:whitenoise/routes.dart'; -import 'package:whitenoise/src/rust/api/users.dart' show KeyPackageStatus; -import 'package:whitenoise/theme.dart'; -import 'package:whitenoise/utils/logging.dart'; -import 'package:whitenoise/utils/metadata.dart'; -import 'package:whitenoise/widgets/wn_button.dart'; -import 'package:whitenoise/widgets/wn_callout.dart'; -import 'package:whitenoise/widgets/wn_icon.dart'; -import 'package:whitenoise/widgets/wn_slate.dart'; -import 'package:whitenoise/widgets/wn_slate_navigation_header.dart'; -import 'package:whitenoise/widgets/wn_system_notice.dart' show WnSystemNotice; -import 'package:whitenoise/widgets/wn_user_profile_card.dart'; - -final _logger = Logger('StartChatScreen'); - -class StartChatScreen extends HookConsumerWidget { - const StartChatScreen({super.key, required this.userPubkey, this.asShade = false}); - - final String userPubkey; - final bool asShade; - - static Future show(BuildContext context, {required String userPubkey}) { - FocusScope.of(context).unfocus(); - final colors = context.colors; - return Navigator.of(context).push( - PageRouteBuilder( - opaque: false, - barrierDismissible: true, - barrierColor: colors.backgroundPrimary.withValues(alpha: 0.8), - pageBuilder: (_, _, _) => StartChatScreen(userPubkey: userPubkey, asShade: true), - transitionsBuilder: (_, animation, _, child) => - FadeTransition(opacity: animation, child: child), - ), - ); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final colors = context.colors; - final accountPubkey = ref.watch(accountPubkeyProvider); - final isSelf = accountPubkey == userPubkey; - - final metadataSnapshot = useUserMetadata(context, userPubkey); - final keyPackageSnapshot = useUserHasKeyPackage(userPubkey); - final ( - :noticeMessage, - :noticeType, - :showErrorNotice, - :showSuccessNotice, - :dismissNotice, - ) = useSystemNotice(); - - final followState = useFollowActions( - accountPubkey: accountPubkey, - userPubkey: userPubkey, - ); - - final dmState = useStartDm( - accountPubkey: accountPubkey, - peerPubkey: userPubkey, - ); - - final metadata = metadataSnapshot.data; - final isFollowing = followState.isFollowing; - final keyPackageStatus = keyPackageSnapshot.data; - final isKeyPackageLoading = keyPackageSnapshot.connectionState == ConnectionState.waiting; - - Future startChat() async { - if (isSelf) return; - final stopWatch = Stopwatch()..start(); - try { - final groupId = await dmState.startDm(); - logDuration( - _logger, - 'startDm took', - stopWatch.elapsedMilliseconds, - ); - - if (context.mounted) { - Routes.goToChat(context, groupId); - } else { - _logger.warning('Context not mounted after DM creation. Aborting navigation.'); - } - } catch (e, stackTrace) { - _logger.severe( - 'Failed to start chat after ${stopWatch.elapsedMilliseconds}ms', - e, - stackTrace, - ); - if (context.mounted) { - showErrorNotice(context.l10n.failedToStartChat); - } - } - } - - Future handleFollowAction() async { - if (isSelf) return; - try { - await followState.toggleFollow(); - } catch (_) { - if (context.mounted) { - showErrorNotice(context.l10n.failedToUpdateFollow); - } - } - } - - Widget validActionsColumn({bool showLoadingStates = true}) { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SizedBox( - width: double.infinity, - child: WnButton( - key: const Key('follow_button'), - text: isFollowing ? context.l10n.unfollow : context.l10n.follow, - type: WnButtonType.outline, - size: WnButtonSize.medium, - trailingIcon: isFollowing ? WnIcons.userUnfollow : WnIcons.userFollow, - loading: showLoadingStates && (followState.isLoading || followState.isActionLoading), - onPressed: handleFollowAction, - ), - ), - Gap(8.h), - SizedBox( - width: double.infinity, - child: WnButton( - key: const Key('add_to_group_button'), - text: context.l10n.addToGroup, - type: WnButtonType.outline, - size: WnButtonSize.medium, - trailingIcon: WnIcons.newGroupChat, - onPressed: () => Routes.pushToAddToGroup(context, userPubkey), - ), - ), - Gap(8.h), - SizedBox( - width: double.infinity, - child: WnButton( - key: const Key('start_chat_button'), - text: context.l10n.sendMessage, - size: WnButtonSize.medium, - trailingIcon: WnIcons.newChat, - loading: showLoadingStates && dmState.isLoading, - onPressed: startChat, - ), - ), - ], - ); - } - - ({String title, String description}) calloutTitleAndDescription() { - final name = presentName(metadata); - if (keyPackageStatus == KeyPackageStatus.incompatible) { - return ( - title: name != null - ? context.l10n.updateNeeded(name) - : context.l10n.unknownUserNeedsUpdate, - description: name != null - ? context.l10n.updateNeededDescription(name) - : context.l10n.unknownUserNeedsUpdateDescription, - ); - } - return ( - title: context.l10n.inviteToWhiteNoise, - description: name != null - ? context.l10n.inviteToWhiteNoiseDescription(name) - : context.l10n.unknownInviteToWhiteNoiseDescription, - ); - } - - return Scaffold( - backgroundColor: asShade ? Colors.transparent : colors.backgroundPrimary, - body: GestureDetector( - key: const Key('start_chat_background'), - onTap: () => Routes.goBack(context), - behavior: HitTestBehavior.opaque, - child: SafeArea( - child: Column( - children: [ - const Spacer(), - WnSlate( - header: WnSlateNavigationHeader( - title: context.l10n.startNewChat, - onNavigate: () => Routes.goBack(context), - ), - systemNotice: noticeMessage != null - ? WnSystemNotice( - key: ValueKey(noticeMessage), - title: noticeMessage, - type: noticeType, - onDismiss: dismissNotice, - ) - : null, - child: SingleChildScrollView( - padding: EdgeInsets.fromLTRB(14.w, 0, 14.w, 14.h), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - WnUserProfileCard( - userPubkey: userPubkey, - metadata: metadata, - onPublicKeyCopied: () => showSuccessNotice(context.l10n.publicKeyCopied), - onPublicKeyCopyError: () => - showErrorNotice(context.l10n.publicKeyCopyError), - ), - Gap(8.h), - if (isSelf) - ...[] - else if (isKeyPackageLoading) - Stack( - alignment: Alignment.center, - children: [ - Visibility( - visible: false, - maintainState: true, - maintainAnimation: true, - maintainSize: true, - child: validActionsColumn(showLoadingStates: false), - ), - CircularProgressIndicator( - color: colors.backgroundContentPrimary, - strokeCap: StrokeCap.round, - ), - ], - ) - else if (keyPackageStatus == KeyPackageStatus.valid) - validActionsColumn() - else ...[ - () { - final callout = calloutTitleAndDescription(); - return WnCallout( - title: callout.title, - description: callout.description, - type: CalloutType.info, - ); - }(), - if (keyPackageStatus == KeyPackageStatus.notFound || - keyPackageStatus == null) ...[ - Gap(8.h), - SizedBox( - width: double.infinity, - child: WnButton( - key: const Key('invite_button'), - text: context.l10n.share, - size: WnButtonSize.medium, - onPressed: () async { - try { - await SharePlus.instance.share( - ShareParams( - text: context.l10n.inviteMessage, - ), - ); - } catch (e) { - _logger.severe('Failed to share invite: $e'); - } - }, - ), - ), - ], - ], - ], - ), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/screens/user_profile_screen.dart b/lib/screens/user_profile_screen.dart new file mode 100644 index 000000000..4592da538 --- /dev/null +++ b/lib/screens/user_profile_screen.dart @@ -0,0 +1,445 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:logging/logging.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:whitenoise/hooks/use_block_actions.dart'; +import 'package:whitenoise/hooks/use_follow_actions.dart'; +import 'package:whitenoise/hooks/use_start_dm.dart'; +import 'package:whitenoise/hooks/use_system_notice.dart'; +import 'package:whitenoise/hooks/use_user_has_key_package.dart'; +import 'package:whitenoise/hooks/use_user_metadata.dart'; +import 'package:whitenoise/l10n/l10n.dart'; +import 'package:whitenoise/providers/account_pubkey_provider.dart'; +import 'package:whitenoise/routes.dart'; +import 'package:whitenoise/src/rust/api/users.dart' show KeyPackageStatus; +import 'package:whitenoise/theme.dart'; +import 'package:whitenoise/utils/logging.dart'; +import 'package:whitenoise/utils/metadata.dart'; +import 'package:whitenoise/widgets/wn_button.dart'; +import 'package:whitenoise/widgets/wn_callout.dart'; +import 'package:whitenoise/widgets/wn_icon.dart'; +import 'package:whitenoise/widgets/wn_overlay.dart'; +import 'package:whitenoise/widgets/wn_slate.dart'; +import 'package:whitenoise/widgets/wn_slate_navigation_header.dart'; +import 'package:whitenoise/widgets/wn_system_notice.dart'; +import 'package:whitenoise/widgets/wn_user_profile_card.dart'; + +final _logger = Logger('UserProfileScreen'); + +class UserProfileScreen extends HookConsumerWidget { + const UserProfileScreen({ + super.key, + required this.userPubkey, + this.asShade = false, + this.topAligned = false, + }); + + final String userPubkey; + final bool asShade; + final bool topAligned; + + static Future show(BuildContext context, {required String userPubkey}) { + FocusScope.of(context).unfocus(); + final colors = context.colors; + return Navigator.of(context).push( + PageRouteBuilder( + opaque: false, + barrierDismissible: true, + barrierColor: colors.backgroundPrimary.withValues(alpha: 0.8), + pageBuilder: (_, _, _) => UserProfileScreen(userPubkey: userPubkey, asShade: true), + transitionsBuilder: (_, animation, _, child) => + FadeTransition(opacity: animation, child: child), + ), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colors = context.colors; + final typography = context.typographyScaled; + final accountPubkey = ref.watch(accountPubkeyProvider); + final isSelf = accountPubkey == userPubkey; + + final metadataSnapshot = useUserMetadata(context, userPubkey); + final keyPackageSnapshot = useUserHasKeyPackage(userPubkey); + final ( + :noticeMessage, + :noticeType, + :showErrorNotice, + :showSuccessNotice, + :dismissNotice, + ) = useSystemNotice(); + + final followState = useFollowActions( + accountPubkey: accountPubkey, + userPubkey: userPubkey, + ); + + final blockState = useBlockActions( + accountPubkey: accountPubkey, + userPubkey: userPubkey, + ); + + final dmState = useStartDm( + accountPubkey: accountPubkey, + peerPubkey: userPubkey, + ); + + final isBlockedNoticeCollapsed = useState(false); + + final metadata = metadataSnapshot.data; + final isFollowing = followState.isFollowing; + final keyPackageStatus = keyPackageSnapshot.data; + final isKeyPackageLoading = keyPackageSnapshot.connectionState == ConnectionState.waiting; + + Future startChat() async { + if (isSelf) return; + final stopWatch = Stopwatch()..start(); + try { + final groupId = await dmState.startDm(); + logDuration(_logger, 'startDm took', stopWatch.elapsedMilliseconds); + + if (context.mounted) { + Routes.goToChat(context, groupId); + } else { + _logger.warning('Context not mounted after DM creation. Aborting navigation.'); + } + } catch (e, stackTrace) { + _logger.severe( + 'Failed to start chat after ${stopWatch.elapsedMilliseconds}ms', + e, + stackTrace, + ); + if (context.mounted) { + showErrorNotice(context.l10n.failedToStartChat); + } + } + } + + Future handleFollowAction() async { + if (isSelf) return; + try { + await followState.toggleFollow(); + } catch (_) { + if (context.mounted) { + showErrorNotice(context.l10n.failedToUpdateFollow); + } + } + } + + Future handleToggleBlock({required bool wasBlocked}) async { + if (isSelf) return; + try { + await blockState.toggleBlock(); + } catch (_) { + if (context.mounted) { + showErrorNotice( + wasBlocked ? context.l10n.failedToUnblockUser : context.l10n.failedToBlockUser, + ); + } + } + } + + Widget validActionsColumn({bool showLoadingStates = true}) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: double.infinity, + child: WnButton( + key: const Key('follow_button'), + text: isFollowing == true ? context.l10n.unfollow : context.l10n.follow, + type: WnButtonType.outline, + size: WnButtonSize.medium, + trailingIcon: isFollowing == true ? WnIcons.userUnfollow : WnIcons.userFollow, + loading: showLoadingStates && (followState.isLoading || followState.isActionLoading), + onPressed: handleFollowAction, + ), + ), + Gap(8.h), + SizedBox( + width: double.infinity, + child: WnButton( + key: const Key('add_to_group_button'), + text: context.l10n.addToGroup, + type: WnButtonType.outline, + size: WnButtonSize.medium, + trailingIcon: WnIcons.newGroupChat, + onPressed: () => Routes.pushToAddToGroup(context, userPubkey), + ), + ), + Gap(8.h), + SizedBox( + width: double.infinity, + child: WnButton( + key: const Key('block_button'), + text: context.l10n.blockUser, + type: WnButtonType.outline, + size: WnButtonSize.medium, + trailingIcon: WnIcons.closeOutline, + loading: showLoadingStates && (blockState.isLoading || blockState.isActionLoading), + disabled: blockState.isLoading || blockState.isActionLoading, + onPressed: () => handleToggleBlock(wasBlocked: false), + ), + ), + Gap(8.h), + SizedBox( + width: double.infinity, + child: WnButton( + key: const Key('start_chat_button'), + text: context.l10n.sendMessage, + size: WnButtonSize.medium, + trailingIcon: WnIcons.newChat, + loading: showLoadingStates && dmState.isLoading, + onPressed: startChat, + ), + ), + ], + ); + } + + ({String title, String description}) calloutTitleAndDescription() { + final name = presentName(metadata); + if (keyPackageStatus == KeyPackageStatus.incompatible) { + return ( + title: name != null + ? context.l10n.updateNeeded(name) + : context.l10n.unknownUserNeedsUpdate, + description: name != null + ? context.l10n.updateNeededDescription(name) + : context.l10n.unknownUserNeedsUpdateDescription, + ); + } + return ( + title: context.l10n.inviteToWhiteNoise, + description: name != null + ? context.l10n.inviteToWhiteNoiseDescription(name) + : context.l10n.unknownInviteToWhiteNoiseDescription, + ); + } + + final isBlocked = blockState.isBlocked; + + final blockedNotice = WnSystemNotice( + key: const Key('blocked_user_detail_notice'), + title: context.l10n.userIsBlocked, + description: Text( + context.l10n.blockedUserDetailDescription, + style: typography.medium14.copyWith( + color: colors.backgroundContentSecondary, + ), + ), + type: WnSystemNoticeType.elevatedCard, + variant: isBlockedNoticeCollapsed.value + ? WnSystemNoticeVariant.collapsed + : WnSystemNoticeVariant.expanded, + animateEntrance: false, + onToggle: () => isBlockedNoticeCollapsed.value = !isBlockedNoticeCollapsed.value, + primaryAction: WnButton( + key: const Key('unblock_button'), + text: context.l10n.unblockUser, + type: WnButtonType.overlay, + size: WnButtonSize.medium, + loading: blockState.isActionLoading, + disabled: blockState.isLoading || blockState.isActionLoading, + trailingIcon: WnIcons.userCheck, + onPressed: () => handleToggleBlock(wasBlocked: true), + ), + ); + + Widget topAlignedBottomPanel() { + if (isBlocked == true) return blockedNotice; + if (isSelf) return const SizedBox.shrink(); + if (isKeyPackageLoading) { + return Padding( + padding: EdgeInsets.fromLTRB(16.w, 0, 16.w, 16.h), + child: Stack( + alignment: Alignment.center, + children: [ + Visibility( + visible: false, + maintainState: true, + maintainAnimation: true, + maintainSize: true, + child: validActionsColumn(showLoadingStates: false), + ), + CircularProgressIndicator( + color: colors.backgroundContentPrimary, + strokeCap: StrokeCap.round, + ), + ], + ), + ); + } + if (keyPackageStatus == KeyPackageStatus.valid) { + return Padding( + padding: EdgeInsets.fromLTRB(16.w, 0, 16.w, 16.h), + child: validActionsColumn(), + ); + } + final callout = calloutTitleAndDescription(); + return Padding( + padding: EdgeInsets.symmetric(horizontal: 16.w), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + WnCallout( + title: callout.title, + description: callout.description, + type: CalloutType.info, + ), + if (keyPackageStatus == KeyPackageStatus.notFound || keyPackageStatus == null) ...[ + Gap(8.h), + SizedBox( + width: double.infinity, + child: WnButton( + key: const Key('invite_button'), + text: context.l10n.share, + size: WnButtonSize.medium, + onPressed: () async { + try { + await SharePlus.instance.share( + ShareParams(text: context.l10n.inviteMessage), + ); + } catch (e) { + _logger.severe('Failed to share invite: $e'); + } + }, + ), + ), + ], + ], + ), + ); + } + + final Widget slateChild = topAligned + ? Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: EdgeInsets.fromLTRB(16.w, 8.h, 16.w, 16.h), + child: WnUserProfileCard( + userPubkey: userPubkey, + metadata: metadata, + onPublicKeyCopied: () => showSuccessNotice(context.l10n.publicKeyCopied), + onPublicKeyCopyError: () => showErrorNotice(context.l10n.publicKeyCopyError), + ), + ), + topAlignedBottomPanel(), + ], + ) + : SingleChildScrollView( + padding: EdgeInsets.fromLTRB(14.w, 0, 14.w, 14.h), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + WnUserProfileCard( + userPubkey: userPubkey, + metadata: metadata, + onPublicKeyCopied: () => showSuccessNotice(context.l10n.publicKeyCopied), + onPublicKeyCopyError: () => showErrorNotice(context.l10n.publicKeyCopyError), + ), + Gap(8.h), + if (isBlocked == true) + blockedNotice + else if (isSelf) + ...[] + else if (isKeyPackageLoading) + Stack( + alignment: Alignment.center, + children: [ + Visibility( + visible: false, + maintainState: true, + maintainAnimation: true, + maintainSize: true, + child: validActionsColumn(showLoadingStates: false), + ), + CircularProgressIndicator( + color: colors.backgroundContentPrimary, + strokeCap: StrokeCap.round, + ), + ], + ) + else if (keyPackageStatus == KeyPackageStatus.valid) + validActionsColumn() + else ...[ + () { + final callout = calloutTitleAndDescription(); + return WnCallout( + title: callout.title, + description: callout.description, + type: CalloutType.info, + ); + }(), + if (keyPackageStatus == KeyPackageStatus.notFound || + keyPackageStatus == null) ...[ + Gap(8.h), + SizedBox( + width: double.infinity, + child: WnButton( + key: const Key('invite_button'), + text: context.l10n.share, + size: WnButtonSize.medium, + onPressed: () async { + try { + await SharePlus.instance.share( + ShareParams(text: context.l10n.inviteMessage), + ); + } catch (e) { + _logger.severe('Failed to share invite: $e'); + } + }, + ), + ), + ], + ], + ], + ), + ); + + final slate = WnSlate( + shrinkWrapContent: topAligned, + header: WnSlateNavigationHeader( + title: context.l10n.profile, + onNavigate: () => Routes.goBack(context), + ), + systemNotice: noticeMessage != null + ? WnSystemNotice( + key: ValueKey(noticeMessage), + title: noticeMessage, + type: noticeType, + onDismiss: dismissNotice, + ) + : null, + child: slateChild, + ); + + return Scaffold( + backgroundColor: (asShade || topAligned) ? Colors.transparent : colors.backgroundPrimary, + body: Stack( + children: [ + if (topAligned) const WnOverlay(variant: WnOverlayVariant.light), + GestureDetector( + key: const Key('user_profile_background'), + onTap: () => Routes.goBack(context), + behavior: HitTestBehavior.opaque, + child: SafeArea( + child: topAligned + ? Align(alignment: Alignment.topCenter, child: slate) + : Column(children: [const Spacer(), slate]), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/user_search_screen.dart b/lib/screens/user_search_screen.dart index 520258789..7ec57bcb4 100644 --- a/lib/screens/user_search_screen.dart +++ b/lib/screens/user_search_screen.dart @@ -106,7 +106,7 @@ class UserSearchScreen extends HookConsumerWidget { pictureUrl: user.metadata.picture, avatarColor: AvatarColor.fromPubkey(user.pubkey), size: WnUserItemSize.big, - onTap: () => Routes.pushToStartChat( + onTap: () => Routes.pushToUserProfile( context, user.pubkey, ), diff --git a/lib/utils/deep_links.dart b/lib/utils/deep_links.dart index 4d0663c0f..4c85aa897 100644 --- a/lib/utils/deep_links.dart +++ b/lib/utils/deep_links.dart @@ -81,7 +81,7 @@ abstract final class DeepLinks { return DeepLinkTarget( type: DeepLinkTargetType.user, - location: '/start-chat/${Uri.encodeComponent(pubkey)}', + location: '/user-profile/${Uri.encodeComponent(pubkey)}', ); } diff --git a/lib/widgets/chat_message_bubble.dart b/lib/widgets/chat_message_bubble.dart index d37cabe5c..efcef5b9a 100644 --- a/lib/widgets/chat_message_bubble.dart +++ b/lib/widgets/chat_message_bubble.dart @@ -3,7 +3,7 @@ import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:whitenoise/hooks/use_chat_messages.dart' show ChatMessageQuoteData; import 'package:whitenoise/l10n/l10n.dart'; -import 'package:whitenoise/screens/start_chat_screen.dart'; +import 'package:whitenoise/screens/user_profile_screen.dart'; import 'package:whitenoise/src/rust/api/markdown.dart'; import 'package:whitenoise/src/rust/api/messages.dart'; import 'package:whitenoise/theme.dart'; @@ -106,7 +106,7 @@ class ChatMessageBubble extends StatelessWidget { if (target.type == DeepLinkTargetType.user) { final hex = hexFromNpub(uri.pathSegments.first); if (hex != null && context.mounted) { - await StartChatScreen.show(context, userPubkey: hex); + await UserProfileScreen.show(context, userPubkey: hex); } return; } @@ -162,7 +162,7 @@ class ChatMessageBubble extends StatelessWidget { if (hrp == MarkdownNostrHrp.npub) { final hex = hexFromNpub(bech32); if (hex != null && context.mounted) { - await StartChatScreen.show(context, userPubkey: hex); + await UserProfileScreen.show(context, userPubkey: hex); } return; } diff --git a/test/routes_test.dart b/test/routes_test.dart index 6a8488e51..4eb14c91b 100644 --- a/test/routes_test.dart +++ b/test/routes_test.dart @@ -7,7 +7,6 @@ import 'package:go_router/go_router.dart'; import 'package:whitenoise/l10n/generated/app_localizations.dart'; import 'package:whitenoise/providers/auth_provider.dart'; import 'package:whitenoise/routes.dart'; -import 'package:whitenoise/screens/blocked_user_screen.dart'; import 'package:whitenoise/screens/blocked_users_screen.dart'; import 'package:whitenoise/screens/chat_info_screen.dart'; import 'package:whitenoise/screens/chat_invite_screen.dart'; @@ -20,8 +19,8 @@ import 'package:whitenoise/screens/login_screen.dart'; import 'package:whitenoise/screens/notification_settings_screen.dart'; import 'package:whitenoise/screens/settings_screen.dart'; import 'package:whitenoise/screens/signup_screen.dart'; -import 'package:whitenoise/screens/start_chat_screen.dart'; import 'package:whitenoise/screens/start_support_chat_screen.dart'; +import 'package:whitenoise/screens/user_profile_screen.dart'; import 'package:whitenoise/screens/user_search_screen.dart'; import 'package:whitenoise/screens/user_selection_screen.dart'; import 'package:whitenoise/src/rust/api/accounts.dart'; @@ -199,7 +198,7 @@ void main() { router.go('whitenoise://user/$testNpubB'); await tester.pumpAndSettle(); - final screen = tester.widget(find.byType(StartChatScreen)); + final screen = tester.widget(find.byType(UserProfileScreen)); expect(screen.userPubkey, testPubkeyB); }); @@ -730,23 +729,23 @@ void main() { }); }); - group('pushToBlockedUser', () { - testWidgets('passes userPubkey into BlockedUserScreen', (tester) async { + group('pushToUserProfile', () { + testWidgets('passes userPubkey into UserProfileScreen', (tester) async { await pumpRouter( tester, overrides: [ authProvider.overrideWith(() => _AuthenticatedAuthNotifier()), ], ); - Routes.pushToBlockedUser(getContext(tester), testPubkeyB); + Routes.pushToUserProfile(getContext(tester), testPubkeyB); await tester.pumpAndSettle(); - final screen = tester.widget(find.byType(BlockedUserScreen)); + final screen = tester.widget(find.byType(UserProfileScreen)); expect(screen.userPubkey, testPubkeyB); }); testWidgets('redirects to LoginScreen when not authenticated', (tester) async { await pumpRouter(tester); - Routes.pushToBlockedUser(getContext(tester), testPubkeyB); + Routes.pushToUserProfile(getContext(tester), testPubkeyB); await tester.pumpAndSettle(); expect(find.byType(LoginScreen), findsOneWidget); }); diff --git a/test/screens/blocked_user_screen_test.dart b/test/screens/blocked_user_screen_test.dart deleted file mode 100644 index 3b6cee83d..000000000 --- a/test/screens/blocked_user_screen_test.dart +++ /dev/null @@ -1,345 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart' show AsyncData; -import 'package:flutter_test/flutter_test.dart'; -import 'package:whitenoise/providers/auth_provider.dart'; -import 'package:whitenoise/routes.dart'; -import 'package:whitenoise/screens/blocked_user_screen.dart'; -import 'package:whitenoise/src/rust/api/metadata.dart'; -import 'package:whitenoise/src/rust/frb_generated.dart'; -import 'package:whitenoise/widgets/wn_avatar.dart'; -import 'package:whitenoise/widgets/wn_button.dart'; -import 'package:whitenoise/widgets/wn_overlay.dart'; -import 'package:whitenoise/widgets/wn_system_notice.dart'; - -import '../mocks/mock_clipboard.dart' show clearClipboardMock, mockClipboard, mockClipboardFailing; -import '../mocks/mock_wn_api.dart'; -import '../test_helpers.dart'; - -const _testPubkey = testPubkeyA; -const _blockedPubkey = testPubkeyB; - -class _MockApi extends MockWnApi { - Completer? unblockCompleter; - Exception? unblockError; - Exception? followError; - String? dmGroupForPeer; - final unblockCalls = <({String account, String target})>[]; - final followCalls = []; - - @override - Future crateApiMuteListUnblockUser({ - required String accountPubkey, - required String targetPubkey, - }) async { - unblockCalls.add((account: accountPubkey, target: targetPubkey)); - if (unblockCompleter != null) await unblockCompleter!.future; - if (unblockError != null) throw unblockError!; - await super.crateApiMuteListUnblockUser( - accountPubkey: accountPubkey, - targetPubkey: targetPubkey, - ); - } - - @override - Future crateApiAccountsFollowUser({ - required String accountPubkey, - required String userToFollowPubkey, - }) async { - followCalls.add(userToFollowPubkey); - if (followError != null) throw followError!; - } - - @override - Future crateApiAccountGroupsGetDmGroupWithPeer({ - required String accountPubkey, - required String peerPubkey, - }) async { - return dmGroupForPeer; - } - - @override - void reset() { - super.reset(); - unblockCompleter = null; - unblockError = null; - followError = null; - dmGroupForPeer = null; - unblockCalls.clear(); - followCalls.clear(); - } -} - -class _MockAuthNotifier extends AuthNotifier { - @override - Future build() async { - state = const AsyncData(_testPubkey); - return _testPubkey; - } -} - -final _api = _MockApi(); - -void main() { - setUpAll(() => RustLib.initMock(api: _api)); - setUp(() { - _api.reset(); - _api.blockedPubkeys.add(_blockedPubkey); - }); - - Future pumpBlockedUserScreen(WidgetTester tester) async { - await mountTestApp( - tester, - overrides: [authProvider.overrideWith(() => _MockAuthNotifier())], - ); - await tester.pumpAndSettle(); - Routes.pushToBlockedUser( - tester.element(find.byType(Scaffold)), - _blockedPubkey, - ); - await tester.pumpAndSettle(); - } - - group('BlockedUserScreen', () { - testWidgets('displays Profile header title', (tester) async { - await pumpBlockedUserScreen(tester); - expect(find.text('Profile'), findsOneWidget); - }); - - testWidgets('uses light overlay variant so the list shows blurred behind', (tester) async { - await pumpBlockedUserScreen(tester); - - final overlay = tester.widget(find.byType(WnOverlay)); - expect(overlay.variant, WnOverlayVariant.light); - }); - - testWidgets('displays user display name when metadata has one', (tester) async { - _api.seedUserInitialSnapshot( - _blockedPubkey, - metadata: const FlutterMetadata(displayName: 'Bob', custom: {}), - ); - await pumpBlockedUserScreen(tester); - - expect(find.text('Bob'), findsOneWidget); - }); - - testWidgets('renders avatar matching the blocked user pubkey', (tester) async { - _api.seedUserInitialSnapshot( - _blockedPubkey, - metadata: const FlutterMetadata(displayName: 'Bob', custom: {}), - ); - await pumpBlockedUserScreen(tester); - - expect( - find.descendant( - of: find.byType(BlockedUserScreen), - matching: find.byType(WnAvatar), - ), - findsOneWidget, - ); - }); - - testWidgets('shows the blocked notice with header and description', (tester) async { - await pumpBlockedUserScreen(tester); - - expect(find.byKey(const Key('blocked_user_detail_notice')), findsOneWidget); - expect(find.text('You blocked this user'), findsOneWidget); - expect( - find.textContaining("You've blocked this user"), - findsOneWidget, - ); - }); - - testWidgets('notice is expanded by default', (tester) async { - await pumpBlockedUserScreen(tester); - - final notice = tester.widget( - find.byKey(const Key('blocked_user_detail_notice')), - ); - expect(notice.variant, WnSystemNoticeVariant.expanded); - expect(find.byKey(const Key('blocked_user_unblock_button')), findsOneWidget); - }); - - testWidgets('notice uses the elevatedCard type so the card stands off the slate', ( - tester, - ) async { - await pumpBlockedUserScreen(tester); - - final notice = tester.widget( - find.byKey(const Key('blocked_user_detail_notice')), - ); - expect(notice.type, WnSystemNoticeType.elevatedCard); - }); - - testWidgets('notice collapses when chevron is tapped', (tester) async { - await pumpBlockedUserScreen(tester); - - await tester.tap(find.byKey(const Key('systemNotice_actionIcon'))); - await tester.pumpAndSettle(); - - final notice = tester.widget( - find.byKey(const Key('blocked_user_detail_notice')), - ); - expect(notice.variant, WnSystemNoticeVariant.collapsed); - }); - - testWidgets('tapping unblock calls the unblock API', (tester) async { - await pumpBlockedUserScreen(tester); - - await tester.tap(find.byKey(const Key('blocked_user_unblock_button'))); - await tester.pumpAndSettle(); - - expect(_api.unblockCalls.length, 1); - expect(_api.unblockCalls[0].account, _testPubkey); - expect(_api.unblockCalls[0].target, _blockedPubkey); - }); - - testWidgets('successful unblock morphs the same screen into the action panel', (tester) async { - await pumpBlockedUserScreen(tester); - - await tester.tap(find.byKey(const Key('blocked_user_unblock_button'))); - await tester.pumpAndSettle(); - - expect(find.byType(BlockedUserScreen), findsOneWidget); - expect(find.byKey(const Key('blocked_user_unblocked_panel')), findsOneWidget); - expect(find.byKey(const Key('blocked_user_detail_notice')), findsNothing); - expect(find.byKey(const Key('blocked_user_send_message_button')), findsOneWidget); - expect(find.byKey(const Key('blocked_user_block_button')), findsOneWidget); - }); - - testWidgets('shows loading state while unblock is in progress', (tester) async { - _api.unblockCompleter = Completer(); - await pumpBlockedUserScreen(tester); - - await tester.tap(find.byKey(const Key('blocked_user_unblock_button'))); - await tester.pump(); - - final button = tester.widget( - find.byKey(const Key('blocked_user_unblock_button')), - ); - expect(button.loading, isTrue); - - _api.unblockCompleter!.complete(); - await tester.pumpAndSettle(); - }); - - testWidgets('shows error notice when unblock fails', (tester) async { - _api.unblockError = Exception('boom'); - await pumpBlockedUserScreen(tester); - - await tester.tap(find.byKey(const Key('blocked_user_unblock_button'))); - await tester.pumpAndSettle(); - - expect( - find.text('Failed to unblock user. Please try again.'), - findsOneWidget, - ); - expect(find.byType(BlockedUserScreen), findsOneWidget); - }); - - testWidgets('shows success notice when npub is copied', (tester) async { - mockClipboard(); - addTearDown(clearClipboardMock); - await pumpBlockedUserScreen(tester); - - await tester.tap(find.byKey(const Key('copy_button'))); - await tester.pumpAndSettle(); - - expect(find.text('Public key copied to clipboard'), findsOneWidget); - }); - - testWidgets('shows error notice when npub copy fails', (tester) async { - mockClipboardFailing(); - addTearDown(clearClipboardMock); - await pumpBlockedUserScreen(tester); - - await tester.tap(find.byKey(const Key('copy_button'))); - await tester.pumpAndSettle(); - - expect(find.text('Failed to copy public key. Please try again.'), findsOneWidget); - }); - - testWidgets('tapping back returns to the previous screen', (tester) async { - await pumpBlockedUserScreen(tester); - - await tester.tap(find.byKey(const Key('slate_back_button'))); - await tester.pumpAndSettle(); - - expect(find.byType(BlockedUserScreen), findsNothing); - }); - - Future unblockToActionPanel(WidgetTester tester) async { - await pumpBlockedUserScreen(tester); - await tester.tap(find.byKey(const Key('blocked_user_unblock_button'))); - await tester.pumpAndSettle(); - } - - testWidgets('tapping Follow on the unblocked panel calls the follow API', (tester) async { - await unblockToActionPanel(tester); - - await tester.tap(find.byKey(const Key('blocked_user_follow_button'))); - await tester.pumpAndSettle(); - - expect(_api.followCalls, [_blockedPubkey]); - }); - - testWidgets('Follow failure surfaces an error notice', (tester) async { - _api.followError = Exception('boom'); - await unblockToActionPanel(tester); - - await tester.tap(find.byKey(const Key('blocked_user_follow_button'))); - await tester.pumpAndSettle(); - - expect( - find.text('Failed to update follow status. Please try again.'), - findsOneWidget, - ); - }); - - testWidgets('tapping Send message navigates to the chat when a DM already exists', ( - tester, - ) async { - _api.dmGroupForPeer = 'existing-group-id'; - await unblockToActionPanel(tester); - - await tester.tap(find.byKey(const Key('blocked_user_send_message_button'))); - await tester.pumpAndSettle(); - - expect(find.byType(BlockedUserScreen), findsNothing); - }); - - testWidgets('Send message failure surfaces an error notice and keeps the screen', ( - tester, - ) async { - await unblockToActionPanel(tester); - - await tester.tap(find.byKey(const Key('blocked_user_send_message_button'))); - await tester.pumpAndSettle(); - - expect(find.text('Failed to start chat. Please try again.'), findsOneWidget); - expect(find.byType(BlockedUserScreen), findsOneWidget); - }); - - testWidgets('tapping Block on the unblocked panel re-blocks and morphs back to the notice', ( - tester, - ) async { - await unblockToActionPanel(tester); - - await tester.tap(find.byKey(const Key('blocked_user_block_button'))); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('blocked_user_detail_notice')), findsOneWidget); - expect(find.byKey(const Key('blocked_user_unblocked_panel')), findsNothing); - }); - - testWidgets('tapping Add to group leaves the BlockedUserScreen', (tester) async { - await unblockToActionPanel(tester); - - await tester.tap(find.byKey(const Key('blocked_user_add_to_group_button'))); - await tester.pumpAndSettle(); - - expect(find.byType(BlockedUserScreen), findsNothing); - }); - }); -} diff --git a/test/screens/blocked_users_screen_test.dart b/test/screens/blocked_users_screen_test.dart index 7d7cc9e67..2fb1f88f6 100644 --- a/test/screens/blocked_users_screen_test.dart +++ b/test/screens/blocked_users_screen_test.dart @@ -5,8 +5,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart' show AsyncData; import 'package:flutter_test/flutter_test.dart'; import 'package:whitenoise/providers/auth_provider.dart'; import 'package:whitenoise/routes.dart'; -import 'package:whitenoise/screens/blocked_user_screen.dart'; import 'package:whitenoise/screens/blocked_users_screen.dart'; +import 'package:whitenoise/screens/user_profile_screen.dart'; import 'package:whitenoise/src/rust/api/metadata.dart'; import 'package:whitenoise/src/rust/api/mute_list.dart'; import 'package:whitenoise/src/rust/frb_generated.dart'; @@ -140,7 +140,7 @@ void main() { expect(find.byKey(const Key('blocked_users_empty')), findsNothing); }); - testWidgets('tapping a row navigates to BlockedUserScreen', (tester) async { + testWidgets('tapping a row navigates to UserProfileScreen', (tester) async { _api.blockedPubkeys.add(_blockedPubkeyB); _api.seedUserInitialSnapshot( _blockedPubkeyB, @@ -152,7 +152,7 @@ void main() { await tester.tap(find.byKey(const Key('blocked_user_tile_$_blockedPubkeyB'))); await tester.pumpAndSettle(); - expect(find.byType(BlockedUserScreen), findsOneWidget); + expect(find.byType(UserProfileScreen), findsOneWidget); }); testWidgets('returning from detail screen refreshes the blocked list', (tester) async { @@ -161,12 +161,12 @@ void main() { await pumpBlockedUsersScreen(tester); final callsBeforeDetail = _api.getBlockedUsersCallCount; - Routes.pushToBlockedUser( + Routes.pushToUserProfile( tester.element(find.byType(BlockedUsersScreen)), _blockedPubkeyB, ); await tester.pumpAndSettle(); - Routes.goBack(tester.element(find.byType(BlockedUserScreen))); + Routes.goBack(tester.element(find.byType(UserProfileScreen))); await tester.pumpAndSettle(); expect(_api.getBlockedUsersCallCount, greaterThan(callsBeforeDetail)); diff --git a/test/screens/scan_npub_screen_test.dart b/test/screens/scan_npub_screen_test.dart index f3a528d87..3e0fedf07 100644 --- a/test/screens/scan_npub_screen_test.dart +++ b/test/screens/scan_npub_screen_test.dart @@ -7,7 +7,7 @@ import 'package:whitenoise/providers/auth_provider.dart'; import 'package:whitenoise/routes.dart'; import 'package:whitenoise/screens/chat_screen.dart'; import 'package:whitenoise/screens/share_profile_screen.dart'; -import 'package:whitenoise/screens/start_chat_screen.dart'; +import 'package:whitenoise/screens/user_profile_screen.dart'; import 'package:whitenoise/src/rust/api/metadata.dart'; import 'package:whitenoise/src/rust/frb_generated.dart'; import 'package:whitenoise/widgets/qr_scanner.dart'; @@ -120,7 +120,7 @@ void main() { scanBox.onBarcodeDetected(testNpubB); await tester.pumpAndSettle(); - expect(find.byType(StartChatScreen), findsOneWidget); + expect(find.byType(UserProfileScreen), findsOneWidget); }); testWidgets('calling onBarcodeDetected with user deep link navigates to start chat', ( @@ -132,7 +132,7 @@ void main() { scanBox.onBarcodeDetected('whitenoise://user/$testNpubB'); await tester.pumpAndSettle(); - final screen = tester.widget(find.byType(StartChatScreen)); + final screen = tester.widget(find.byType(UserProfileScreen)); expect(screen.userPubkey, testPubkeyB); }); diff --git a/test/screens/start_chat_screen_test.dart b/test/screens/user_profile_screen_test.dart similarity index 84% rename from test/screens/start_chat_screen_test.dart rename to test/screens/user_profile_screen_test.dart index 4ea0dcbf8..feb644230 100644 --- a/test/screens/start_chat_screen_test.dart +++ b/test/screens/user_profile_screen_test.dart @@ -143,7 +143,7 @@ void main() { setUpAll(() => RustLib.initMock(api: _api)); setUp(() => _api.reset()); - Future pumpStartChatScreen( + Future pumpUserProfileScreen( WidgetTester tester, { required String userPubkey, bool settle = true, @@ -154,7 +154,7 @@ void main() { overrides: [authProvider.overrideWith(() => _MockAuthNotifier())], ); await tester.pumpAndSettle(); - Routes.pushToStartChat( + Routes.pushToUserProfile( tester.element(find.byType(Scaffold)), userPubkey, ); @@ -166,41 +166,41 @@ void main() { } } - group('StartChatScreen', () { + group('UserProfileScreen', () { testWidgets('displays slate container', (tester) async { - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); expect(find.byType(WnSlate), findsOneWidget); }); testWidgets('displays title', (tester) async { - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); - expect(find.text('Start new chat'), findsOneWidget); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); + expect(find.text('Profile'), findsOneWidget); }); testWidgets('displays back button', (tester) async { - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); expect(find.byKey(const Key('slate_back_button')), findsOneWidget); }); testWidgets('displays avatar', (tester) async { - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); expect(find.byType(WnAvatar), findsOneWidget); }); testWidgets('shows pubkey copy card', (tester) async { - await pumpStartChatScreen(tester, userPubkey: testPubkeyA); + await pumpUserProfileScreen(tester, userPubkey: testPubkeyA); final copyCard = tester.widget(find.byType(WnCopyCard)); expect(copyCard.textToDisplay, testNpubAFormatted); expect(copyCard.textToCopy, testNpubA); }); testWidgets('displays follow button', (tester) async { - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); expect(find.byKey(const Key('follow_button')), findsOneWidget); }); testWidgets('displays start chat button', (tester) async { - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); expect(find.byKey(const Key('start_chat_button')), findsOneWidget); expect(find.text('Send message'), findsOneWidget); }); @@ -208,7 +208,7 @@ void main() { testWidgets('keeps button layout stable while key package loads', (tester) async { _api.userHasKeyPackageCompleter = Completer(); - await pumpStartChatScreen(tester, userPubkey: _otherPubkey, settle: false); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey, settle: false); expect(find.byType(CircularProgressIndicator), findsOneWidget); expect(find.byKey(const Key('follow_button')), findsOneWidget); @@ -225,14 +225,14 @@ void main() { }); testWidgets('does not show self action buttons for own profile', (tester) async { - await pumpStartChatScreen(tester, userPubkey: _testPubkey); + await pumpUserProfileScreen(tester, userPubkey: _testPubkey); expect(find.byKey(const Key('follow_button')), findsNothing); expect(find.byKey(const Key('add_to_group_button')), findsNothing); expect(find.byKey(const Key('start_chat_button')), findsNothing); }); testWidgets('does not show invite button when user has valid key package', (tester) async { - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); expect(find.byKey(const Key('invite_button')), findsNothing); }); @@ -247,17 +247,17 @@ void main() { }); testWidgets('displays user name', (tester) async { - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); expect(find.text('Alice'), findsOneWidget); }); testWidgets('displays nip05', (tester) async { - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); expect(find.text('alice@example.com'), findsOneWidget); }); testWidgets('displays about', (tester) async { - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); expect(find.text('I love Nostr!'), findsOneWidget); }); }); @@ -275,17 +275,17 @@ void main() { }); testWidgets('still displays about', (tester) async { - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); expect(find.text('I love Nostr!'), findsOneWidget); }); testWidgets('still displays nip05', (tester) async { - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); expect(find.text('alice@example.com'), findsOneWidget); }); testWidgets('passes picture url to avatar', (tester) async { - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); expect(find.byType(WnAvatar), findsOneWidget); final avatar = tester.widget(find.byType(WnAvatar)); @@ -295,18 +295,18 @@ void main() { group('follow button', () { testWidgets('shows Follow for non-followed user', (tester) async { - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); expect(find.text('Follow'), findsOneWidget); }); testWidgets('shows Unfollow for followed user', (tester) async { _api.followingPubkeys.add(_otherPubkey); - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); expect(find.text('Unfollow'), findsOneWidget); }); testWidgets('calls follow API when tapped', (tester) async { - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); await tester.tap(find.byKey(const Key('follow_button'))); await tester.pumpAndSettle(); @@ -317,7 +317,7 @@ void main() { testWidgets('calls unfollow API when tapped', (tester) async { _api.followingPubkeys.add(_otherPubkey); - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); await tester.tap(find.byKey(const Key('follow_button'))); await tester.pumpAndSettle(); @@ -328,7 +328,7 @@ void main() { testWidgets('shows loading state during follow', (tester) async { _api.followCompleter = Completer(); - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); await tester.tap(find.byKey(const Key('follow_button'))); await tester.pump(); @@ -339,7 +339,7 @@ void main() { testWidgets('shows system notice on follow error', (tester) async { _api.followError = Exception('Network error'); - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); await tester.tap(find.byKey(const Key('follow_button'))); await tester.pump(); @@ -352,7 +352,7 @@ void main() { group('add to group button', () { testWidgets('displays add to group button', (tester) async { - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); expect(find.byKey(const Key('add_to_group_button')), findsOneWidget); expect(find.text('Add to group'), findsOneWidget); }); @@ -361,14 +361,14 @@ void main() { tester, ) async { _api.userHasKeyPackageStatus = KeyPackageStatus.notFound; - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); expect(find.byKey(const Key('add_to_group_button')), findsNothing); }); testWidgets('tapping add to group button navigates to add to group screen', ( tester, ) async { - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); await tester.tap(find.byKey(const Key('add_to_group_button'))); await tester.pumpAndSettle(); @@ -378,7 +378,7 @@ void main() { group('start chat action', () { testWidgets('calls createGroup API with correct params', (tester) async { - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); await tester.tap(find.byKey(const Key('start_chat_button'))); await tester.pumpAndSettle(); @@ -390,7 +390,7 @@ void main() { testWidgets('shows loading state during creation', (tester) async { _api.createGroupCompleter = Completer(); - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); await tester.tap(find.byKey(const Key('start_chat_button'))); await tester.pump(); @@ -402,7 +402,7 @@ void main() { testWidgets('shows system notice on failure', (tester) async { _api.createGroupError = Exception('Network error'); - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); await tester.tap(find.byKey(const Key('start_chat_button'))); await tester.pumpAndSettle(); @@ -415,14 +415,14 @@ void main() { tester, ) async { _api.createGroupCompleter = Completer(); - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); await tester.tap(find.byKey(const Key('start_chat_button'))); await tester.pump(); await tester.tap(find.byKey(const Key('slate_back_button'))); await tester.pumpAndSettle(); - expect(find.text('Start new chat'), findsNothing); + expect(find.text('Profile'), findsNothing); _api.createGroupCompleter!.complete( Group( @@ -444,21 +444,21 @@ void main() { group('back button', () { testWidgets('navigates back when tapped', (tester) async { - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); await tester.tap(find.byKey(const Key('slate_back_button'))); await tester.pumpAndSettle(); - expect(find.text('Start new chat'), findsNothing); + expect(find.text('Profile'), findsNothing); }); }); group('background tap', () { testWidgets('navigates back when background tapped', (tester) async { - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); await tester.tapAt(const Offset(10, 10)); await tester.pumpAndSettle(); - expect(find.text('Start new chat'), findsNothing); + expect(find.text('Profile'), findsNothing); }); }); @@ -468,27 +468,27 @@ void main() { }); testWidgets('shows invite callout', (tester) async { - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); expect(find.text('Invite to White Noise'), findsOneWidget); }); testWidgets('does not show follow button', (tester) async { - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); expect(find.byKey(const Key('follow_button')), findsNothing); }); testWidgets('does not show start chat button', (tester) async { - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); expect(find.byKey(const Key('start_chat_button')), findsNothing); }); testWidgets('shows invite button', (tester) async { - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); expect(find.byKey(const Key('invite_button')), findsOneWidget); }); testWidgets('invite button shows correct label', (tester) async { - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); final button = tester.widget(find.byKey(const Key('invite_button'))); expect(button.text, 'Share'); }); @@ -496,7 +496,7 @@ void main() { testWidgets('handles share failure gracefully', (tester) async { mockSharePlusFailing(); addTearDown(clearSharePlusMock); - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); await tester.tap(find.byKey(const Key('invite_button'))); await tester.pumpAndSettle(); @@ -509,7 +509,7 @@ void main() { ) async { final shareCalls = mockSharePlus(); addTearDown(clearSharePlusMock); - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); await tester.tap(find.byKey(const Key('invite_button'))); await tester.pumpAndSettle(); @@ -531,7 +531,7 @@ void main() { name: 'alice_nostr', custom: {}, ); - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); expect( find.text("Alice isn't on White Noise yet. Share the app to start a secure chat."), findsOneWidget, @@ -540,7 +540,7 @@ void main() { testWidgets('uses name when displayName is absent', (tester) async { _api.metadata = const FlutterMetadata(name: 'bob_nostr', custom: {}); - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); expect( find.text( "bob_nostr isn't on White Noise yet. Share the app to start a secure chat.", @@ -550,7 +550,7 @@ void main() { }); testWidgets('uses generic message when no metadata names', (tester) async { - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); expect( find.text( "This user isn't on White Noise yet. Share the app to start a secure chat.", @@ -567,22 +567,22 @@ void main() { }); testWidgets('shows user needs update callout', (tester) async { - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); expect(find.text('Update required'), findsOneWidget); }); testWidgets('does not show follow button', (tester) async { - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); expect(find.byKey(const Key('follow_button')), findsNothing); }); testWidgets('does not show start chat button', (tester) async { - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); expect(find.byKey(const Key('start_chat_button')), findsNothing); }); testWidgets('does not show invite button', (tester) async { - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); expect(find.byKey(const Key('invite_button')), findsNothing); }); @@ -593,7 +593,7 @@ void main() { name: 'alice_nostr', custom: {}, ); - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); expect( find.text( "You can't start a secure chat with Alice yet. They need to update White Noise before secure messaging works.", @@ -604,7 +604,7 @@ void main() { testWidgets('uses name when displayName is absent', (tester) async { _api.metadata = const FlutterMetadata(name: 'bob_nostr', custom: {}); - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); expect( find.text( "You can't start a secure chat with bob_nostr yet. They need to update White Noise before secure messaging works.", @@ -614,7 +614,7 @@ void main() { }); testWidgets('uses generic message when no metadata names', (tester) async { - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); expect( find.text( "You can't start a secure chat with this user yet. They need to update White Noise before secure messaging works.", @@ -628,7 +628,7 @@ void main() { group('system notice', () { testWidgets('shows notice when public key is copied', (tester) async { mockClipboard(); - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); await tester.tap(find.byKey(const Key('copy_button'))); await tester.pump(); @@ -639,7 +639,7 @@ void main() { testWidgets('shows error notice when public key copy fails', (tester) async { mockClipboardFailing(); addTearDown(clearClipboardMock); - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); await tester.tap(find.byKey(const Key('copy_button'))); await tester.pumpAndSettle(); @@ -649,7 +649,7 @@ void main() { testWidgets('dismisses notice after auto-hide duration', (tester) async { mockClipboard(); - await pumpStartChatScreen(tester, userPubkey: _otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); await tester.tap(find.byKey(const Key('copy_button'))); await tester.pump(); diff --git a/test/screens/user_search_screen_test.dart b/test/screens/user_search_screen_test.dart index c48369224..ec2a13fde 100644 --- a/test/screens/user_search_screen_test.dart +++ b/test/screens/user_search_screen_test.dart @@ -375,7 +375,7 @@ void main() { await tester.tap(find.text('Bob')); await tester.pumpAndSettle(); - expect(find.text('Start new chat'), findsOneWidget); + expect(find.text('Profile'), findsOneWidget); expect(find.byKey(const Key('start_chat_button')), findsOneWidget); }); @@ -401,7 +401,7 @@ void main() { await tester.tap(find.text('Found User')); await tester.pumpAndSettle(); - expect(find.text('Start new chat'), findsOneWidget); + expect(find.text('Profile'), findsOneWidget); expect(find.byKey(const Key('start_chat_button')), findsOneWidget); }); }); diff --git a/test/utils/deep_links_test.dart b/test/utils/deep_links_test.dart index c982ef5a4..bb07254e2 100644 --- a/test/utils/deep_links_test.dart +++ b/test/utils/deep_links_test.dart @@ -14,19 +14,19 @@ void main() { test('maps production user links to the user profile route', () { final target = DeepLinks.parse(Uri.parse('whitenoise://user/$testNpubB')); - expect(target?.location, '/start-chat/$testPubkeyB'); + expect(target?.location, '/user-profile/$testPubkeyB'); }); test('maps staging user links to the user profile route', () { final target = DeepLinks.parse(Uri.parse('whitenoise-staging://user/$testNpubB')); - expect(target?.location, '/start-chat/$testPubkeyB'); + expect(target?.location, '/user-profile/$testPubkeyB'); }); test('maps triple-slash user links to the user profile route', () { final target = DeepLinks.parse(Uri.parse('whitenoise:///user/$testNpubB')); - expect(target?.location, '/start-chat/$testPubkeyB'); + expect(target?.location, '/user-profile/$testPubkeyB'); }); test('maps chat links to the chat route', () { diff --git a/test/widgets/chat_message_bubble_test.dart b/test/widgets/chat_message_bubble_test.dart index 6652b38b9..56fcbee7d 100644 --- a/test/widgets/chat_message_bubble_test.dart +++ b/test/widgets/chat_message_bubble_test.dart @@ -10,7 +10,7 @@ import 'package:url_launcher_platform_interface/link.dart'; import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; import 'package:whitenoise/hooks/use_chat_messages.dart' show ChatMessageQuoteData; import 'package:whitenoise/l10n/generated/app_localizations.dart'; -import 'package:whitenoise/screens/start_chat_screen.dart'; +import 'package:whitenoise/screens/user_profile_screen.dart'; import 'package:whitenoise/src/rust/api/markdown.dart'; import 'package:whitenoise/src/rust/api/media_files.dart'; import 'package:whitenoise/src/rust/api/messages.dart'; @@ -882,7 +882,7 @@ void main() { expect(launcher.calls, isEmpty); }); - testWidgets('npub mention tap opens StartChatScreen as shade', (tester) async { + testWidgets('npub mention tap opens UserProfileScreen as shade', (tester) async { await _mountBubbleWithRouter( tester, ChatMessageBubble( @@ -912,9 +912,9 @@ void main() { await tester.pump(const Duration(milliseconds: 50)); tester.takeException(); expect(launcher.calls, isEmpty); - expect(find.byType(StartChatScreen), findsOneWidget); + expect(find.byType(UserProfileScreen), findsOneWidget); expect( - tester.widget(find.byType(StartChatScreen)).asShade, + tester.widget(find.byType(UserProfileScreen)).asShade, isTrue, ); }); @@ -948,7 +948,7 @@ void main() { expect(find.byKey(const ValueKey('chat_route_$testGroupId')), findsOneWidget); }); - testWidgets('whitenoise://user/ tap opens StartChatScreen as shade', (tester) async { + testWidgets('whitenoise://user/ tap opens UserProfileScreen as shade', (tester) async { await _mountBubbleWithRouter( tester, ChatMessageBubble( @@ -974,15 +974,15 @@ void main() { (link.recognizer as TapGestureRecognizer).onTap!(); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); - // StartChatScreen reads accountPubkeyProvider which throws without auth in tests. + // UserProfileScreen reads accountPubkeyProvider which throws without auth in tests. // The shade still pushes onto the navigator — drain the expected build error // so the framework doesn't flag it. tester.takeException(); expect(launcher.calls, isEmpty); expect(find.byKey(const ValueKey('user_route_$testPubkeyA')), findsNothing); - expect(find.byType(StartChatScreen), findsOneWidget); + expect(find.byType(UserProfileScreen), findsOneWidget); expect( - tester.widget(find.byType(StartChatScreen)).asShade, + tester.widget(find.byType(UserProfileScreen)).asShade, isTrue, ); }); From 176543111175648ee275f7678253a453a497e4aa Mon Sep 17 00:00:00 2001 From: Mubarak Auwal Date: Wed, 20 May 2026 22:32:30 +0100 Subject: [PATCH 11/15] Cover topAligned mode, blocked notice transitions, and UserProfileScreen.show in tests --- test/screens/user_profile_screen_test.dart | 246 +++++++++++++++++++++ 1 file changed, 246 insertions(+) diff --git a/test/screens/user_profile_screen_test.dart b/test/screens/user_profile_screen_test.dart index feb644230..540b52ffc 100644 --- a/test/screens/user_profile_screen_test.dart +++ b/test/screens/user_profile_screen_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart' show AsyncData; import 'package:flutter_test/flutter_test.dart'; import 'package:whitenoise/providers/auth_provider.dart'; import 'package:whitenoise/routes.dart'; +import 'package:whitenoise/screens/user_profile_screen.dart'; import 'package:whitenoise/src/rust/api/groups.dart'; import 'package:whitenoise/src/rust/api/metadata.dart'; import 'package:whitenoise/src/rust/api/users.dart'; @@ -42,6 +43,10 @@ class _MockApi extends MockWnApi { Completer? followCompleter; Exception? followError; final Set followingPubkeys = {}; + final blockCalls = <({String account, String target})>[]; + final unblockCalls = <({String account, String target})>[]; + Exception? blockError; + Exception? unblockError; @override Future crateApiUsersUserMetadata({ @@ -112,6 +117,32 @@ class _MockApi extends MockWnApi { return groupsList; } + @override + Future crateApiMuteListBlockUser({ + required String accountPubkey, + required String targetPubkey, + }) async { + blockCalls.add((account: accountPubkey, target: targetPubkey)); + if (blockError != null) throw blockError!; + await super.crateApiMuteListBlockUser( + accountPubkey: accountPubkey, + targetPubkey: targetPubkey, + ); + } + + @override + Future crateApiMuteListUnblockUser({ + required String accountPubkey, + required String targetPubkey, + }) async { + unblockCalls.add((account: accountPubkey, target: targetPubkey)); + if (unblockError != null) throw unblockError!; + await super.crateApiMuteListUnblockUser( + accountPubkey: accountPubkey, + targetPubkey: targetPubkey, + ); + } + @override void reset() { super.reset(); @@ -126,6 +157,10 @@ class _MockApi extends MockWnApi { followError = null; followingPubkeys.clear(); groupsList = []; + blockCalls.clear(); + unblockCalls.clear(); + blockError = null; + unblockError = null; } } @@ -147,6 +182,7 @@ void main() { WidgetTester tester, { required String userPubkey, bool settle = true, + bool topAligned = false, }) async { setUpTestView(tester); await mountTestApp( @@ -157,6 +193,7 @@ void main() { Routes.pushToUserProfile( tester.element(find.byType(Scaffold)), userPubkey, + topAligned: topAligned, ); if (settle) { await tester.pumpAndSettle(); @@ -662,5 +699,214 @@ void main() { expect(find.text('Public key copied to clipboard'), findsNothing); }); }); + + group('topAligned mode (from blocked-users list)', () { + testWidgets('renders the blocked notice when the target is already blocked', ( + tester, + ) async { + _api.blockedPubkeys.add(_otherPubkey); + await pumpUserProfileScreen( + tester, + userPubkey: _otherPubkey, + topAligned: true, + ); + + expect(find.byKey(const Key('blocked_user_detail_notice')), findsOneWidget); + expect(find.byKey(const Key('unblock_button')), findsOneWidget); + }); + + testWidgets('renders the action panel when the target is not blocked', ( + tester, + ) async { + await pumpUserProfileScreen( + tester, + userPubkey: _otherPubkey, + topAligned: true, + ); + + expect(find.byKey(const Key('blocked_user_detail_notice')), findsNothing); + expect(find.byKey(const Key('block_button')), findsOneWidget); + expect(find.byKey(const Key('start_chat_button')), findsOneWidget); + }); + + testWidgets('tapping Unblock calls the unblock API', (tester) async { + _api.blockedPubkeys.add(_otherPubkey); + await pumpUserProfileScreen( + tester, + userPubkey: _otherPubkey, + topAligned: true, + ); + + await tester.tap(find.byKey(const Key('unblock_button'))); + await tester.pumpAndSettle(); + + expect(_api.unblockCalls.length, 1); + expect(_api.unblockCalls[0].target, _otherPubkey); + }); + + testWidgets('Unblock failure surfaces an error notice', (tester) async { + _api.blockedPubkeys.add(_otherPubkey); + _api.unblockError = Exception('boom'); + await pumpUserProfileScreen( + tester, + userPubkey: _otherPubkey, + topAligned: true, + ); + + await tester.tap(find.byKey(const Key('unblock_button'))); + await tester.pumpAndSettle(); + + expect( + find.text('Failed to unblock user. Please try again.'), + findsOneWidget, + ); + }); + + testWidgets('tapping Block transitions to the blocked notice', (tester) async { + await pumpUserProfileScreen( + tester, + userPubkey: _otherPubkey, + topAligned: true, + ); + + await tester.tap(find.byKey(const Key('block_button'))); + await tester.pumpAndSettle(); + + expect(_api.blockCalls.length, 1); + expect(find.byKey(const Key('blocked_user_detail_notice')), findsOneWidget); + expect(find.byKey(const Key('unblock_button')), findsOneWidget); + }); + + testWidgets('Block failure surfaces an error notice', (tester) async { + _api.blockError = Exception('boom'); + await pumpUserProfileScreen( + tester, + userPubkey: _otherPubkey, + topAligned: true, + ); + + await tester.tap(find.byKey(const Key('block_button'))); + await tester.pumpAndSettle(); + + expect( + find.text('Failed to block user. Please try again.'), + findsOneWidget, + ); + }); + + testWidgets('tapping outside the slate dismisses the popup', (tester) async { + await pumpUserProfileScreen( + tester, + userPubkey: _otherPubkey, + topAligned: true, + ); + + await tester.tapAt(const Offset(50, 750)); + await tester.pumpAndSettle(); + + expect(find.byType(UserProfileScreen), findsNothing); + }); + + testWidgets('toggles the blocked notice between collapsed and expanded', ( + tester, + ) async { + _api.blockedPubkeys.add(_otherPubkey); + await pumpUserProfileScreen( + tester, + userPubkey: _otherPubkey, + topAligned: true, + ); + + final initial = tester.widget( + find.byKey(const Key('blocked_user_detail_notice')), + ); + expect(initial.variant, WnSystemNoticeVariant.expanded); + + await tester.tap(find.byKey(const Key('systemNotice_actionIcon'))); + await tester.pumpAndSettle(); + + final collapsed = tester.widget( + find.byKey(const Key('blocked_user_detail_notice')), + ); + expect(collapsed.variant, WnSystemNoticeVariant.collapsed); + }); + + testWidgets('renders the invite share button when peer has no key package', ( + tester, + ) async { + _api.userHasKeyPackageStatus = KeyPackageStatus.notFound; + await pumpUserProfileScreen( + tester, + userPubkey: _otherPubkey, + topAligned: true, + ); + + expect(find.byKey(const Key('invite_button')), findsOneWidget); + }); + + testWidgets('shows success notice on public key copy', (tester) async { + mockClipboard(); + addTearDown(clearClipboardMock); + await pumpUserProfileScreen( + tester, + userPubkey: _otherPubkey, + topAligned: true, + ); + + await tester.tap(find.byKey(const Key('copy_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Public key copied to clipboard'), findsOneWidget); + }); + + testWidgets('shows error notice on public key copy failure', (tester) async { + mockClipboardFailing(); + addTearDown(clearClipboardMock); + await pumpUserProfileScreen( + tester, + userPubkey: _otherPubkey, + topAligned: true, + ); + + await tester.tap(find.byKey(const Key('copy_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Failed to copy public key. Please try again.'), findsOneWidget); + }); + }); + + group('bottom-aligned mode (default chat/search entry)', () { + testWidgets('renders the blocked notice when the target is already blocked', ( + tester, + ) async { + _api.blockedPubkeys.add(_otherPubkey); + await pumpUserProfileScreen(tester, userPubkey: _otherPubkey); + + expect(find.byKey(const Key('blocked_user_detail_notice')), findsOneWidget); + expect(find.byKey(const Key('unblock_button')), findsOneWidget); + }); + }); + + group('UserProfileScreen.show', () { + testWidgets('opens the screen as a modal with asShade enabled', (tester) async { + await mountTestApp( + tester, + overrides: [authProvider.overrideWith(() => _MockAuthNotifier())], + ); + await tester.pumpAndSettle(); + + unawaited( + UserProfileScreen.show( + tester.element(find.byType(Scaffold)), + userPubkey: _otherPubkey, + ), + ); + await tester.pumpAndSettle(); + + final screen = tester.widget(find.byType(UserProfileScreen)); + expect(screen.asShade, isTrue); + expect(screen.userPubkey, _otherPubkey); + }); + }); }); } From 12c2e98f62f386be5432ddd5b0b6448aab3ac163 Mon Sep 17 00:00:00 2001 From: Mubarak Auwal Date: Wed, 20 May 2026 23:03:24 +0100 Subject: [PATCH 12/15] Cover topAligned invite share tap and failure paths --- test/screens/user_profile_screen_test.dart | 34 ++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/test/screens/user_profile_screen_test.dart b/test/screens/user_profile_screen_test.dart index 540b52ffc..379b6bdad 100644 --- a/test/screens/user_profile_screen_test.dart +++ b/test/screens/user_profile_screen_test.dart @@ -844,6 +844,40 @@ void main() { expect(find.byKey(const Key('invite_button')), findsOneWidget); }); + testWidgets('tapping invite button calls SharePlus with invite message', ( + tester, + ) async { + final shareCalls = mockSharePlus(); + addTearDown(clearSharePlusMock); + _api.userHasKeyPackageStatus = KeyPackageStatus.notFound; + await pumpUserProfileScreen( + tester, + userPubkey: _otherPubkey, + topAligned: true, + ); + + await tester.tap(find.byKey(const Key('invite_button'))); + await tester.pumpAndSettle(); + + expect(shareCalls, hasLength(1)); + }); + + testWidgets('invite share failure is swallowed without rethrowing', (tester) async { + mockSharePlusFailing(); + addTearDown(clearSharePlusMock); + _api.userHasKeyPackageStatus = KeyPackageStatus.notFound; + await pumpUserProfileScreen( + tester, + userPubkey: _otherPubkey, + topAligned: true, + ); + + await tester.tap(find.byKey(const Key('invite_button'))); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + }); + testWidgets('shows success notice on public key copy', (tester) async { mockClipboard(); addTearDown(clearClipboardMock); From eb3e2a19fefffd6f447584781967c640132d1297 Mon Sep 17 00:00:00 2001 From: Mubarak Auwal Date: Thu, 21 May 2026 08:58:16 +0100 Subject: [PATCH 13/15] Fall back to bech32 npub for the blocked user tile display name and drop stale start-chat route stub --- lib/screens/blocked_users_screen.dart | 2 +- test/widgets/chat_message_bubble_test.dart | 8 -------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/lib/screens/blocked_users_screen.dart b/lib/screens/blocked_users_screen.dart index b4e0b08bf..f9a87de85 100644 --- a/lib/screens/blocked_users_screen.dart +++ b/lib/screens/blocked_users_screen.dart @@ -142,8 +142,8 @@ class _BlockedUserTile extends StatelessWidget { @override Widget build(BuildContext context) { - final displayName = presentName(metadata) ?? pubkey.substring(0, 8); final npub = npubFromHex(pubkey); + final displayName = presentName(metadata) ?? npub ?? pubkey.substring(0, 8); return WnUserItem( key: Key('blocked_user_tile_$pubkey'), diff --git a/test/widgets/chat_message_bubble_test.dart b/test/widgets/chat_message_bubble_test.dart index 56fcbee7d..9ceece559 100644 --- a/test/widgets/chat_message_bubble_test.dart +++ b/test/widgets/chat_message_bubble_test.dart @@ -979,7 +979,6 @@ void main() { // so the framework doesn't flag it. tester.takeException(); expect(launcher.calls, isEmpty); - expect(find.byKey(const ValueKey('user_route_$testPubkeyA')), findsNothing); expect(find.byType(UserProfileScreen), findsOneWidget); expect( tester.widget(find.byType(UserProfileScreen)).asShade, @@ -1068,13 +1067,6 @@ Future _mountBubbleWithRouter(WidgetTester tester, Widget bubble) async { body: const SizedBox(), ), ), - GoRoute( - path: '/start-chat/:pubkey', - builder: (_, state) => Scaffold( - key: ValueKey('user_route_${state.pathParameters['pubkey']}'), - body: const SizedBox(), - ), - ), ], ); await tester.pumpWidget( From e860b0a4dac4a4c2e6db2c85e510c696832931cd Mon Sep 17 00:00:00 2001 From: Mubarak Auwal Date: Thu, 21 May 2026 12:21:35 +0100 Subject: [PATCH 14/15] Drop the WnSystemNotice elevatedCard variant and use fillSecondary on the blocked user notice --- lib/screens/user_profile_screen.dart | 3 ++- lib/theme/semantic_colors.dart | 11 ----------- lib/widgets/wn_system_notice.dart | 11 +++-------- test/widgets/wn_system_notice_test.dart | 22 ++++++++++++++++++++++ 4 files changed, 27 insertions(+), 20 deletions(-) diff --git a/lib/screens/user_profile_screen.dart b/lib/screens/user_profile_screen.dart index 4592da538..33b6101f4 100644 --- a/lib/screens/user_profile_screen.dart +++ b/lib/screens/user_profile_screen.dart @@ -233,7 +233,8 @@ class UserProfileScreen extends HookConsumerWidget { color: colors.backgroundContentSecondary, ), ), - type: WnSystemNoticeType.elevatedCard, + type: WnSystemNoticeType.neutral, + backgroundColor: colors.fillSecondary, variant: isBlockedNoticeCollapsed.value ? WnSystemNoticeVariant.collapsed : WnSystemNoticeVariant.expanded, diff --git a/lib/theme/semantic_colors.dart b/lib/theme/semantic_colors.dart index a6e063c9f..a380bc976 100644 --- a/lib/theme/semantic_colors.dart +++ b/lib/theme/semantic_colors.dart @@ -506,7 +506,6 @@ class SemanticColors extends ThemeExtension { final Color backgroundSecondary; final Color backgroundTertiary; final Color backgroundSlate; - final Color backgroundSlateElevated; final Color backgroundMessageIncoming; final Color backgroundContentPrimary; final Color backgroundContentSecondary; @@ -561,7 +560,6 @@ class SemanticColors extends ThemeExtension { required this.backgroundSecondary, required this.backgroundTertiary, required this.backgroundSlate, - required this.backgroundSlateElevated, required this.backgroundMessageIncoming, required this.backgroundContentPrimary, required this.backgroundContentSecondary, @@ -617,7 +615,6 @@ class SemanticColors extends ThemeExtension { backgroundSecondary: _NeutralColors.neutral50, backgroundTertiary: _NeutralColors.neutral100, backgroundSlate: _NeutralColors.neutral50, - backgroundSlateElevated: _NeutralColors.neutral100, backgroundMessageIncoming: _NeutralColors.neutral100, backgroundContentPrimary: _NeutralColors.neutral950, backgroundContentSecondary: _NeutralColors.neutral500, @@ -673,7 +670,6 @@ class SemanticColors extends ThemeExtension { backgroundSecondary: _NeutralColors.neutral950, backgroundTertiary: _NeutralColors.neutral900, backgroundSlate: _NeutralColors.neutral900, - backgroundSlateElevated: _NeutralColors.neutral850, backgroundMessageIncoming: _NeutralColors.neutral800, backgroundContentPrimary: _BaseColors.white, backgroundContentSecondary: _NeutralColors.neutral400, @@ -730,7 +726,6 @@ class SemanticColors extends ThemeExtension { Color? backgroundSecondary, Color? backgroundTertiary, Color? backgroundSlate, - Color? backgroundSlateElevated, Color? backgroundMessageIncoming, Color? backgroundContentPrimary, Color? backgroundContentSecondary, @@ -785,7 +780,6 @@ class SemanticColors extends ThemeExtension { backgroundSecondary: backgroundSecondary ?? this.backgroundSecondary, backgroundTertiary: backgroundTertiary ?? this.backgroundTertiary, backgroundSlate: backgroundSlate ?? this.backgroundSlate, - backgroundSlateElevated: backgroundSlateElevated ?? this.backgroundSlateElevated, backgroundMessageIncoming: backgroundMessageIncoming ?? this.backgroundMessageIncoming, backgroundContentPrimary: backgroundContentPrimary ?? this.backgroundContentPrimary, backgroundContentSecondary: backgroundContentSecondary ?? this.backgroundContentSecondary, @@ -847,11 +841,6 @@ class SemanticColors extends ThemeExtension { backgroundSecondary: Color.lerp(backgroundSecondary, other.backgroundSecondary, t)!, backgroundTertiary: Color.lerp(backgroundTertiary, other.backgroundTertiary, t)!, backgroundSlate: Color.lerp(backgroundSlate, other.backgroundSlate, t)!, - backgroundSlateElevated: Color.lerp( - backgroundSlateElevated, - other.backgroundSlateElevated, - t, - )!, backgroundMessageIncoming: Color.lerp( backgroundMessageIncoming, other.backgroundMessageIncoming, diff --git a/lib/widgets/wn_system_notice.dart b/lib/widgets/wn_system_notice.dart index d63c8c3d9..113d44ac8 100644 --- a/lib/widgets/wn_system_notice.dart +++ b/lib/widgets/wn_system_notice.dart @@ -9,7 +9,6 @@ import 'package:whitenoise/widgets/wn_icon.dart'; enum WnSystemNoticeType { neutral, - elevatedCard, info, success, warning, @@ -39,6 +38,7 @@ class WnSystemNotice extends HookWidget { this.onToggle, this.autoHideDuration, this.animateEntrance = true, + this.backgroundColor, }); final String title; @@ -51,6 +51,7 @@ class WnSystemNotice extends HookWidget { final VoidCallback? onToggle; final Duration? autoHideDuration; final bool animateEntrance; + final Color? backgroundColor; bool get _isCollapsed => variant == WnSystemNoticeVariant.collapsed; bool get _isExpanded => variant == WnSystemNoticeVariant.expanded; @@ -144,7 +145,7 @@ class WnSystemNotice extends HookWidget { child: Container( padding: EdgeInsets.all(16.w), decoration: BoxDecoration( - color: bgColor, + color: backgroundColor ?? bgColor, ), child: Column( mainAxisSize: MainAxisSize.min, @@ -232,12 +233,6 @@ class WnSystemNotice extends HookWidget { colors.backgroundContentPrimary, null, ); - case WnSystemNoticeType.elevatedCard: - return ( - colors.backgroundSlateElevated, - colors.backgroundContentPrimary, - null, - ); case WnSystemNoticeType.info: return ( colors.intentionInfoBackground, diff --git a/test/widgets/wn_system_notice_test.dart b/test/widgets/wn_system_notice_test.dart index 25299df84..7250cf79f 100644 --- a/test/widgets/wn_system_notice_test.dart +++ b/test/widgets/wn_system_notice_test.dart @@ -96,6 +96,28 @@ void main() { ); expect(find.byType(WnIcon), findsNothing); }); + + testWidgets('backgroundColor overrides type-derived bg', (tester) async { + const overrideColor = Color(0xFF123456); + await mountWidget( + const WnSystemNotice( + title: 'Override', + type: WnSystemNoticeType.neutral, + backgroundColor: overrideColor, + ), + tester, + ); + final container = tester.widget( + find + .descendant( + of: find.byType(WnSystemNotice), + matching: find.byType(Container), + ) + .first, + ); + final decoration = container.decoration as BoxDecoration; + expect(decoration.color, overrideColor); + }); }); group('WnSystemNotice Variants', () { From 53fec188bd5b63eb9dc725241b1563db7f9d9001 Mon Sep 17 00:00:00 2001 From: Mubarak Auwal Date: Thu, 21 May 2026 15:55:11 +0100 Subject: [PATCH 15/15] Address pepi's review on the blocked notice and open user profiles top-aligned from start-chat entries --- lib/screens/scan_npub_screen.dart | 2 +- lib/screens/user_profile_screen.dart | 9 ++++----- lib/screens/user_search_screen.dart | 1 + lib/widgets/wn_system_notice.dart | 6 ++---- test/screens/user_profile_screen_test.dart | 1 + test/widgets/wn_system_notice_test.dart | 22 ---------------------- 6 files changed, 9 insertions(+), 32 deletions(-) diff --git a/lib/screens/scan_npub_screen.dart b/lib/screens/scan_npub_screen.dart index 47f004e4d..129a07cf8 100644 --- a/lib/screens/scan_npub_screen.dart +++ b/lib/screens/scan_npub_screen.dart @@ -32,7 +32,7 @@ class ScanNpubScreen extends HookWidget { final hexPubkey = hexFromNpub(value); if (hexPubkey != null) { Routes.goBack(context); - Routes.pushToUserProfile(context, hexPubkey); + Routes.pushToUserProfile(context, hexPubkey, topAligned: true); } else if (value.startsWith('npub1')) { showInvalidNpubError.value = true; } diff --git a/lib/screens/user_profile_screen.dart b/lib/screens/user_profile_screen.dart index 33b6101f4..38a2d81ae 100644 --- a/lib/screens/user_profile_screen.dart +++ b/lib/screens/user_profile_screen.dart @@ -43,13 +43,13 @@ class UserProfileScreen extends HookConsumerWidget { static Future show(BuildContext context, {required String userPubkey}) { FocusScope.of(context).unfocus(); - final colors = context.colors; return Navigator.of(context).push( PageRouteBuilder( opaque: false, barrierDismissible: true, - barrierColor: colors.backgroundPrimary.withValues(alpha: 0.8), - pageBuilder: (_, _, _) => UserProfileScreen(userPubkey: userPubkey, asShade: true), + barrierColor: Colors.transparent, + pageBuilder: (_, _, _) => + UserProfileScreen(userPubkey: userPubkey, asShade: true, topAligned: true), transitionsBuilder: (_, animation, _, child) => FadeTransition(opacity: animation, child: child), ), @@ -230,11 +230,10 @@ class UserProfileScreen extends HookConsumerWidget { description: Text( context.l10n.blockedUserDetailDescription, style: typography.medium14.copyWith( - color: colors.backgroundContentSecondary, + color: colors.backgroundContentQuaternary, ), ), type: WnSystemNoticeType.neutral, - backgroundColor: colors.fillSecondary, variant: isBlockedNoticeCollapsed.value ? WnSystemNoticeVariant.collapsed : WnSystemNoticeVariant.expanded, diff --git a/lib/screens/user_search_screen.dart b/lib/screens/user_search_screen.dart index 7ec57bcb4..8b93e810e 100644 --- a/lib/screens/user_search_screen.dart +++ b/lib/screens/user_search_screen.dart @@ -109,6 +109,7 @@ class UserSearchScreen extends HookConsumerWidget { onTap: () => Routes.pushToUserProfile( context, user.pubkey, + topAligned: true, ), ); }, diff --git a/lib/widgets/wn_system_notice.dart b/lib/widgets/wn_system_notice.dart index 113d44ac8..4baa82183 100644 --- a/lib/widgets/wn_system_notice.dart +++ b/lib/widgets/wn_system_notice.dart @@ -38,7 +38,6 @@ class WnSystemNotice extends HookWidget { this.onToggle, this.autoHideDuration, this.animateEntrance = true, - this.backgroundColor, }); final String title; @@ -51,7 +50,6 @@ class WnSystemNotice extends HookWidget { final VoidCallback? onToggle; final Duration? autoHideDuration; final bool animateEntrance; - final Color? backgroundColor; bool get _isCollapsed => variant == WnSystemNoticeVariant.collapsed; bool get _isExpanded => variant == WnSystemNoticeVariant.expanded; @@ -145,7 +143,7 @@ class WnSystemNotice extends HookWidget { child: Container( padding: EdgeInsets.all(16.w), decoration: BoxDecoration( - color: backgroundColor ?? bgColor, + color: bgColor, ), child: Column( mainAxisSize: MainAxisSize.min, @@ -229,7 +227,7 @@ class WnSystemNotice extends HookWidget { switch (type) { case WnSystemNoticeType.neutral: return ( - colors.backgroundSecondary, + colors.backgroundTertiary, colors.backgroundContentPrimary, null, ); diff --git a/test/screens/user_profile_screen_test.dart b/test/screens/user_profile_screen_test.dart index 379b6bdad..a76fbc3dc 100644 --- a/test/screens/user_profile_screen_test.dart +++ b/test/screens/user_profile_screen_test.dart @@ -939,6 +939,7 @@ void main() { final screen = tester.widget(find.byType(UserProfileScreen)); expect(screen.asShade, isTrue); + expect(screen.topAligned, isTrue); expect(screen.userPubkey, _otherPubkey); }); }); diff --git a/test/widgets/wn_system_notice_test.dart b/test/widgets/wn_system_notice_test.dart index 7250cf79f..25299df84 100644 --- a/test/widgets/wn_system_notice_test.dart +++ b/test/widgets/wn_system_notice_test.dart @@ -96,28 +96,6 @@ void main() { ); expect(find.byType(WnIcon), findsNothing); }); - - testWidgets('backgroundColor overrides type-derived bg', (tester) async { - const overrideColor = Color(0xFF123456); - await mountWidget( - const WnSystemNotice( - title: 'Override', - type: WnSystemNoticeType.neutral, - backgroundColor: overrideColor, - ), - tester, - ); - final container = tester.widget( - find - .descendant( - of: find.byType(WnSystemNotice), - matching: find.byType(Container), - ) - .first, - ); - final decoration = container.decoration as BoxDecoration; - expect(decoration.color, overrideColor); - }); }); group('WnSystemNotice Variants', () {