UI Polish: Signup Screen, Callout, IconButton (#519)

* UI Polish: Signup Screen, Callout, IconButton

* Update CHANGELOG.md

* FadeTransition

* Fix carousel close allowing taps during animation

* The de di do

* rm 'it stays private'
This commit is contained in:
emir yorulmaz
2026-03-23 11:18:27 +03:00
committed by GitHub
parent ad7062609b
commit 1ccdb80abd
24 changed files with 449 additions and 217 deletions
+1
View File
@@ -35,6 +35,7 @@ and this project adheres to Calendar Versioning (CalVer).
### Removed
### Fixed
- UI Polish: Signup Screen, Callout, IconButton [PR #519](https://github.com/marmot-protocol/whitenoise/pull/519)
### Security
+2 -2
View File
@@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 2L3 7L3.705 7.705L7.5 3.915V14H8.5V3.915L12.295 7.705L13 7L8 2Z" fill="black" style="fill:black;fill-opacity:1;"/>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 2.25L3.375 7.875L4.16812 8.66812L8.4375 4.40437V15.75H9.5625V4.40437L13.8319 8.66812L14.625 7.875L9 2.25Z" fill="black" style="fill:black;fill-opacity:1;"/>
</svg>

Before

Width:  |  Height:  |  Size: 232 B

After

Width:  |  Height:  |  Size: 274 B

+6 -3
View File
@@ -11,10 +11,13 @@
"enterPrivateKey": "Privaten Schlüssel eingeben",
"nsecPlaceholder": "nsec...",
"setupProfile": "Profil einrichten",
"chooseName": "Namen wählen",
"createProfile": "Profil erstellen",
"chooseName": "Name",
"enterYourName": "Namen eingeben",
"introduceYourself": "Stell dich vor",
"writeSomethingAboutYourself": "Schreib etwas über dich",
"introduceYourself": "Über dich",
"writeSomethingAboutYourself": "Stell dich vor",
"profilePrivacyTitle": "Du bestimmst, was du teilst",
"profilePrivacyDescription": "Name, Foto und Bio sind im globalen Nostr-Netzwerk sichtbar. Verwende Angaben, mit denen du dich wohlfühlst.",
"cancel": "Abbrechen",
"profileReady": "Dein Profil ist bereit!",
"startConversationHint": "Starte ein Gespräch, indem du Freunde hinzufügst oder dein Profil teilst.",
+16 -4
View File
@@ -46,11 +46,15 @@
"@nsecPlaceholder": {
"description": "Placeholder text for nsec input"
},
"setupProfile": "Setup profile",
"setupProfile": "Set up profile",
"@setupProfile": {
"description": "Title on signup screen"
},
"chooseName": "Choose a name",
"createProfile": "Create profile",
"@createProfile": {
"description": "Submit button text on signup screen"
},
"chooseName": "Name",
"@chooseName": {
"description": "Label for name input field"
},
@@ -58,14 +62,22 @@
"@enterYourName": {
"description": "Placeholder for name input"
},
"introduceYourself": "Introduce yourself",
"introduceYourself": "About",
"@introduceYourself": {
"description": "Label for bio input field"
},
"writeSomethingAboutYourself": "Write something about yourself",
"writeSomethingAboutYourself": "Introduce yourself",
"@writeSomethingAboutYourself": {
"description": "Placeholder for bio input"
},
"profilePrivacyTitle": "You control what you share",
"@profilePrivacyTitle": {
"description": "Title of the privacy notice on signup screen"
},
"profilePrivacyDescription": "Name, photo, and bio are visible on the global Nostr network. Use what you're comfortable sharing.",
"@profilePrivacyDescription": {
"description": "Description of the privacy notice on signup screen"
},
"cancel": "Cancel",
"@cancel": {
"description": "Cancel button text"
+6 -3
View File
@@ -11,10 +11,13 @@
"enterPrivateKey": "Introduce tu llave privada",
"nsecPlaceholder": "nsec...",
"setupProfile": "Configurar perfil",
"chooseName": "Elige un nombre",
"createProfile": "Crear perfil",
"chooseName": "Nombre",
"enterYourName": "Introduce tu nombre",
"introduceYourself": "Preséntate",
"writeSomethingAboutYourself": "Escribe algo sobre ti",
"introduceYourself": "Acerca de",
"writeSomethingAboutYourself": "Preséntate",
"profilePrivacyTitle": "Tú controlas lo que compartes",
"profilePrivacyDescription": "El nombre, la foto y la biografía son visibles en la red global de Nostr. Usa lo que te resulte cómodo compartir.",
"cancel": "Cancelar",
"profileReady": "¡Tu perfil está listo!",
"startConversationHint": "Inicia una conversación añadiendo amigos o compartiendo tu perfil.",
+6 -3
View File
@@ -11,10 +11,13 @@
"enterPrivateKey": "Entrez votre clé privée",
"nsecPlaceholder": "nsec...",
"setupProfile": "Configurer le profil",
"chooseName": "Choisissez un nom",
"createProfile": "Créer le profil",
"chooseName": "Nom",
"enterYourName": "Entrez votre nom",
"introduceYourself": "Présentez-vous",
"writeSomethingAboutYourself": "Écrivez quelque chose sur vous",
"introduceYourself": "À propos",
"writeSomethingAboutYourself": "Présentez-vous",
"profilePrivacyTitle": "Vous contrôlez ce que vous partagez",
"profilePrivacyDescription": "Le nom, la photo et la bio sont visibles sur le réseau mondial de Nostr. Utilisez ce que vous êtes à l'aise de partager.",
"cancel": "Annuler",
"profileReady": "Votre profil est prêt !",
"startConversationHint": "Démarrez une conversation en ajoutant des amis ou en partageant votre profil.",
+6 -3
View File
@@ -11,10 +11,13 @@
"enterPrivateKey": "Inserisci la tua chiave privata",
"nsecPlaceholder": "nsec...",
"setupProfile": "Configura profilo",
"chooseName": "Scegli un nome",
"createProfile": "Crea profilo",
"chooseName": "Nome",
"enterYourName": "Inserisci il tuo nome",
"introduceYourself": "Presentati",
"writeSomethingAboutYourself": "Scrivi qualcosa su di te",
"introduceYourself": "Informazioni",
"writeSomethingAboutYourself": "Presentati",
"profilePrivacyTitle": "Controlli tu cosa condividi",
"profilePrivacyDescription": "Nome, foto e bio sono visibili sulla rete globale di Nostr. Usa ciò che ti senti a tuo agio nel condividere.",
"cancel": "Annulla",
"profileReady": "Il tuo profilo è pronto!",
"startConversationHint": "Inizia una conversazione aggiungendo amici o condividendo il tuo profilo.",
+6 -3
View File
@@ -11,10 +11,13 @@
"enterPrivateKey": "Digite sua chave privada",
"nsecPlaceholder": "nsec...",
"setupProfile": "Configurar perfil",
"chooseName": "Escolha um nome",
"createProfile": "Criar perfil",
"chooseName": "Nome",
"enterYourName": "Digite seu nome",
"introduceYourself": "Apresente-se",
"writeSomethingAboutYourself": "Escreva algo sobre você",
"introduceYourself": "Sobre",
"writeSomethingAboutYourself": "Apresente-se",
"profilePrivacyTitle": "Você controla o que compartilha",
"profilePrivacyDescription": "Nome, foto e bio são visíveis na rede global do Nostr. Use o que você se sentir confortável em compartilhar.",
"cancel": "Cancelar",
"profileReady": "Seu perfil está pronto!",
"startConversationHint": "Inicie uma conversa adicionando amigos ou compartilhando seu perfil.",
+6 -3
View File
@@ -11,10 +11,13 @@
"enterPrivateKey": "Введите ваш приватный ключ",
"nsecPlaceholder": "nsec...",
"setupProfile": "Настройка профиля",
"chooseName": "Выберите имя",
"createProfile": "Создать профиль",
"chooseName": "Имя",
"enterYourName": "Введите ваше имя",
"introduceYourself": "Расскажите о себе",
"writeSomethingAboutYourself": "Напишите что-нибудь о себе",
"introduceYourself": "О себе",
"writeSomethingAboutYourself": "Расскажите о себе",
"profilePrivacyTitle": "Вы контролируете, чем делитесь",
"profilePrivacyDescription": "Имя, фото и биография видны в глобальной сети Nostr. Используйте то, чем вам комфортно делиться.",
"cancel": "Отмена",
"profileReady": "Ваш профиль готов!",
"startConversationHint": "Начните разговор, добавив друзей или поделившись своим профилем.",
+7 -4
View File
@@ -10,11 +10,14 @@
"loginTitle": "Özel anahtarınızı girin",
"enterPrivateKey": "Özel anahtarınızı girin",
"nsecPlaceholder": "nsec...",
"setupProfile": "Profil ayarla",
"chooseName": "Bir isim seçin",
"setupProfile": "Profil oluştur",
"createProfile": "Profil oluştur",
"chooseName": "İsim",
"enterYourName": "Adınızı girin",
"introduceYourself": "Kendinizi tanıtın",
"writeSomethingAboutYourself": "Kendiniz hakkında bir şeyler yazın",
"introduceYourself": "Hakkında",
"writeSomethingAboutYourself": "Kendinizi tanıtın",
"profilePrivacyTitle": "Ne paylaştığınızı siz kontrol edersiniz",
"profilePrivacyDescription": "İsim, fotoğraf ve biyografi küresel Nostr ağında görünür. Paylaşırken kendinizi rahat hissedeceğiniz bilgileri kullanın.",
"cancel": "İptal",
"profileReady": "Profiliniz hazır!",
"startConversationHint": "Arkadaş ekleyerek veya profilinizi paylaşarak bir sohbet başlatın.",
+22 -4
View File
@@ -171,13 +171,19 @@ abstract class AppLocalizations {
/// Title on signup screen
///
/// In en, this message translates to:
/// **'Setup profile'**
/// **'Set up profile'**
String get setupProfile;
/// Submit button text on signup screen
///
/// In en, this message translates to:
/// **'Create profile'**
String get createProfile;
/// Label for name input field
///
/// In en, this message translates to:
/// **'Choose a name'**
/// **'Name'**
String get chooseName;
/// Placeholder for name input
@@ -189,15 +195,27 @@ abstract class AppLocalizations {
/// Label for bio input field
///
/// In en, this message translates to:
/// **'Introduce yourself'**
/// **'About'**
String get introduceYourself;
/// Placeholder for bio input
///
/// In en, this message translates to:
/// **'Write something about yourself'**
/// **'Introduce yourself'**
String get writeSomethingAboutYourself;
/// Title of the privacy notice on signup screen
///
/// In en, this message translates to:
/// **'You control what you share'**
String get profilePrivacyTitle;
/// Description of the privacy notice on signup screen
///
/// In en, this message translates to:
/// **'Name, photo, and bio are visible on the global Nostr network. Use what you\'re comfortable sharing.'**
String get profilePrivacyDescription;
/// Cancel button text
///
/// In en, this message translates to:
+13 -3
View File
@@ -50,16 +50,26 @@ class AppLocalizationsDe extends AppLocalizations {
String get setupProfile => 'Profil einrichten';
@override
String get chooseName => 'Namen wählen';
String get createProfile => 'Profil erstellen';
@override
String get chooseName => 'Name';
@override
String get enterYourName => 'Namen eingeben';
@override
String get introduceYourself => 'Stell dich vor';
String get introduceYourself => 'Über dich';
@override
String get writeSomethingAboutYourself => 'Schreib etwas über dich';
String get writeSomethingAboutYourself => 'Stell dich vor';
@override
String get profilePrivacyTitle => 'Du bestimmst, was du teilst';
@override
String get profilePrivacyDescription =>
'Name, Foto und Bio sind im globalen Nostr-Netzwerk sichtbar. Verwende Angaben, mit denen du dich wohlfühlst.';
@override
String get cancel => 'Abbrechen';
+14 -4
View File
@@ -47,19 +47,29 @@ class AppLocalizationsEn extends AppLocalizations {
String get nsecPlaceholder => 'nsec...';
@override
String get setupProfile => 'Setup profile';
String get setupProfile => 'Set up profile';
@override
String get chooseName => 'Choose a name';
String get createProfile => 'Create profile';
@override
String get chooseName => 'Name';
@override
String get enterYourName => 'Enter your name';
@override
String get introduceYourself => 'Introduce yourself';
String get introduceYourself => 'About';
@override
String get writeSomethingAboutYourself => 'Write something about yourself';
String get writeSomethingAboutYourself => 'Introduce yourself';
@override
String get profilePrivacyTitle => 'You control what you share';
@override
String get profilePrivacyDescription =>
'Name, photo, and bio are visible on the global Nostr network. Use what you\'re comfortable sharing.';
@override
String get cancel => 'Cancel';
+13 -3
View File
@@ -50,16 +50,26 @@ class AppLocalizationsEs extends AppLocalizations {
String get setupProfile => 'Configurar perfil';
@override
String get chooseName => 'Elige un nombre';
String get createProfile => 'Crear perfil';
@override
String get chooseName => 'Nombre';
@override
String get enterYourName => 'Introduce tu nombre';
@override
String get introduceYourself => 'Preséntate';
String get introduceYourself => 'Acerca de';
@override
String get writeSomethingAboutYourself => 'Escribe algo sobre ti';
String get writeSomethingAboutYourself => 'Preséntate';
@override
String get profilePrivacyTitle => 'Tú controlas lo que compartes';
@override
String get profilePrivacyDescription =>
'El nombre, la foto y la biografía son visibles en la red global de Nostr. Usa lo que te resulte cómodo compartir.';
@override
String get cancel => 'Cancelar';
+13 -3
View File
@@ -50,16 +50,26 @@ class AppLocalizationsFr extends AppLocalizations {
String get setupProfile => 'Configurer le profil';
@override
String get chooseName => 'Choisissez un nom';
String get createProfile => 'Créer le profil';
@override
String get chooseName => 'Nom';
@override
String get enterYourName => 'Entrez votre nom';
@override
String get introduceYourself => 'Présentez-vous';
String get introduceYourself => 'À propos';
@override
String get writeSomethingAboutYourself => 'Écrivez quelque chose sur vous';
String get writeSomethingAboutYourself => 'Présentez-vous';
@override
String get profilePrivacyTitle => 'Vous contrôlez ce que vous partagez';
@override
String get profilePrivacyDescription =>
'Le nom, la photo et la bio sont visibles sur le réseau mondial de Nostr. Utilisez ce que vous êtes à l\'aise de partager.';
@override
String get cancel => 'Annuler';
+13 -3
View File
@@ -50,16 +50,26 @@ class AppLocalizationsIt extends AppLocalizations {
String get setupProfile => 'Configura profilo';
@override
String get chooseName => 'Scegli un nome';
String get createProfile => 'Crea profilo';
@override
String get chooseName => 'Nome';
@override
String get enterYourName => 'Inserisci il tuo nome';
@override
String get introduceYourself => 'Presentati';
String get introduceYourself => 'Informazioni';
@override
String get writeSomethingAboutYourself => 'Scrivi qualcosa su di te';
String get writeSomethingAboutYourself => 'Presentati';
@override
String get profilePrivacyTitle => 'Controlli tu cosa condividi';
@override
String get profilePrivacyDescription =>
'Nome, foto e bio sono visibili sulla rete globale di Nostr. Usa ciò che ti senti a tuo agio nel condividere.';
@override
String get cancel => 'Annulla';
+13 -3
View File
@@ -50,16 +50,26 @@ class AppLocalizationsPt extends AppLocalizations {
String get setupProfile => 'Configurar perfil';
@override
String get chooseName => 'Escolha um nome';
String get createProfile => 'Criar perfil';
@override
String get chooseName => 'Nome';
@override
String get enterYourName => 'Digite seu nome';
@override
String get introduceYourself => 'Apresente-se';
String get introduceYourself => 'Sobre';
@override
String get writeSomethingAboutYourself => 'Escreva algo sobre você';
String get writeSomethingAboutYourself => 'Apresente-se';
@override
String get profilePrivacyTitle => 'Você controla o que compartilha';
@override
String get profilePrivacyDescription =>
'Nome, foto e bio são visíveis na rede global do Nostr. Use o que você se sentir confortável em compartilhar.';
@override
String get cancel => 'Cancelar';
+13 -3
View File
@@ -50,16 +50,26 @@ class AppLocalizationsRu extends AppLocalizations {
String get setupProfile => 'Настройка профиля';
@override
String get chooseName => 'Выберите имя';
String get createProfile => 'Создать профиль';
@override
String get chooseName => 'Имя';
@override
String get enterYourName => 'Введите ваше имя';
@override
String get introduceYourself => 'Расскажите о себе';
String get introduceYourself => 'О себе';
@override
String get writeSomethingAboutYourself => 'Напишите что-нибудь о себе';
String get writeSomethingAboutYourself => 'Расскажите о себе';
@override
String get profilePrivacyTitle => 'Вы контролируете, чем делитесь';
@override
String get profilePrivacyDescription =>
'Имя, фото и биография видны в глобальной сети Nostr. Используйте то, чем вам комфортно делиться.';
@override
String get cancel => 'Отмена';
+14 -4
View File
@@ -47,19 +47,29 @@ class AppLocalizationsTr extends AppLocalizations {
String get nsecPlaceholder => 'nsec...';
@override
String get setupProfile => 'Profil ayarla';
String get setupProfile => 'Profil oluştur';
@override
String get chooseName => 'Bir isim seçin';
String get createProfile => 'Profil oluştur';
@override
String get chooseName => 'İsim';
@override
String get enterYourName => 'Adınızı girin';
@override
String get introduceYourself => 'Kendinizi tanıtın';
String get introduceYourself => 'Hakkında';
@override
String get writeSomethingAboutYourself => 'Kendiniz hakkında bir şeyler yazın';
String get writeSomethingAboutYourself => 'Kendinizi tanıtın';
@override
String get profilePrivacyTitle => 'Ne paylaştığınızı siz kontrol edersiniz';
@override
String get profilePrivacyDescription =>
'İsim, fotoğraf ve biyografi küresel Nostr ağında görünür. Paylaşırken kendinizi rahat hissedeceğiniz bilgileri kullanın.';
@override
String get cancel => 'İptal';
+148 -114
View File
@@ -22,8 +22,9 @@ import 'package:whitenoise/routes.dart' show Routes;
import 'package:whitenoise/theme.dart';
import 'package:whitenoise/widgets/wn_avatar.dart' show WnAvatar, WnAvatarSize;
import 'package:whitenoise/widgets/wn_button.dart';
import 'package:whitenoise/widgets/wn_callout.dart';
import 'package:whitenoise/widgets/wn_carousel_indicator.dart' show WnCarouselIndicator;
import 'package:whitenoise/widgets/wn_icon.dart' show WnIcons;
import 'package:whitenoise/widgets/wn_icon.dart' show WnIcon, WnIcons;
import 'package:whitenoise/widgets/wn_input.dart' show WnInput;
import 'package:whitenoise/widgets/wn_input_text_area.dart' show WnInputTextArea;
import 'package:whitenoise/widgets/wn_onboarding_carousel.dart' show WnOnboardingCarousel;
@@ -67,6 +68,7 @@ class SignupScreen extends HookConsumerWidget {
onImageSelected: onImageSelected,
);
final noticeMessage = useState<String?>(null);
final privacyNoticeExpanded = useState(false);
final showCarousel = useState(false);
final carouselIndex = useState(0);
final carouselAccentColor = useState<Color>(context.colors.accent.cyan.contentSecondary);
@@ -163,132 +165,165 @@ class SignupScreen extends HookConsumerWidget {
body: Stack(
fit: StackFit.expand,
children: [
if (!showCarousel.value)
Positioned.fill(
Positioned.fill(
child: IgnorePointer(
ignoring: showCarousel.value,
child: GestureDetector(
key: const Key('signup_background'),
onTap: () => Routes.goBack(context),
behavior: HitTestBehavior.translucent,
),
),
),
SafeArea(
child: Align(
alignment: Alignment.topCenter,
child: _LearnMoreButton(
key: const Key('learn_more_button'),
onTap: openCarousel,
visible: !showCarousel.value,
child: FadeTransition(
opacity: CurvedAnimation(
parent: ModalRoute.of(context)?.animation ?? kAlwaysCompleteAnimation,
curve: const Interval(0.5, 1.0),
),
child: Align(
alignment: Alignment.topCenter,
child: SizedBox(
height: 148.h,
child: Center(
child: _LearnMoreButton(
key: const Key('learn_more_button'),
onTap: openCarousel,
visible: !showCarousel.value,
),
),
),
),
),
),
SafeArea(
child: Column(
children: [
SizedBox(height: 70.h),
SizedBox(
height: (148.h - MediaQuery.of(context).viewInsets.bottom).clamp(0.0, 148.h),
),
Expanded(
child: SlideTransition(
position: slideAnimation,
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Flexible(
child: WnSlate(
header: WnSlateNavigationHeader(
title: context.l10n.setupProfile,
onNavigate: () => Routes.goBack(context),
),
systemNotice: noticeMessage.value != null
? WnSystemNotice(
key: ValueKey(noticeMessage.value),
title: noticeMessage.value!,
type: WnSystemNoticeType.error,
onDismiss: dismissNotice,
)
: null,
child: SingleChildScrollView(
controller: scrollController,
child: Padding(
padding: EdgeInsets.fromLTRB(
14.w,
0,
14.w,
14.h,
child: FadeTransition(
opacity: ReverseAnimation(fadeAnimation),
child: SlideTransition(
position: slideAnimation,
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Flexible(
child: WnSlate(
header: WnSlateNavigationHeader(
title: context.l10n.setupProfile,
onNavigate: () => Routes.goBack(context),
),
systemNotice: noticeMessage.value != null
? WnSystemNotice(
key: ValueKey(noticeMessage.value),
title: noticeMessage.value!,
type: WnSystemNoticeType.error,
onDismiss: dismissNotice,
)
: null,
footer: Padding(
padding: EdgeInsets.only(
left: 14.w,
right: 14.w,
bottom: 14.h,
),
child: Column(
spacing: 16.h,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: ValueListenableBuilder(
valueListenable: displayNameController,
builder: (context, value, child) {
return WnAvatar(
pictureUrl: state.selectedImagePath,
displayName: value.text,
size: WnAvatarSize.large,
onEditTap: state.isLoading ? null : pickImage,
);
},
),
),
WnInput(
label: context.l10n.chooseName,
placeholder: context.l10n.enterYourName,
controller: displayNameController,
errorText: state.displayNameError,
onChanged: (_) => clearErrors(),
inlineActionIcon: WnIcons.closeSmall,
inlineActionKey: const Key(
'signup_display_name_clear_button',
),
inlineActionOnPressed: () {
displayNameController.clear();
clearErrors();
},
),
WnInputTextArea(
key: const Key('signup_bio_field'),
label: context.l10n.introduceYourself,
placeholder: context.l10n.writeSomethingAboutYourself,
controller: bioController,
focusNode: bioFocusNode,
textInputAction: TextInputAction.done,
),
Column(
spacing: 8.h,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
WnButton(
text: context.l10n.cancel,
type: WnButtonType.outline,
onPressed: () => Routes.goBack(context),
disabled: state.isLoading,
),
WnButton(
text: context.l10n.signUp,
onPressed: onSubmit,
ValueListenableBuilder(
valueListenable: displayNameController,
builder: (context, value, child) {
final hasName = value.text.trim().isNotEmpty;
return WnButton(
text: context.l10n.createProfile,
onPressed: hasName ? onSubmit : null,
loading: state.isLoading,
disabled: state.isLoading,
),
],
disabled: !hasName || state.isLoading,
);
},
),
],
),
),
child: SingleChildScrollView(
controller: scrollController,
child: Padding(
padding: EdgeInsets.fromLTRB(
14.w,
8.h,
14.w,
14.h,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: ValueListenableBuilder(
valueListenable: displayNameController,
builder: (context, value, child) {
return WnAvatar(
pictureUrl: state.selectedImagePath,
displayName: value.text,
size: WnAvatarSize.large,
onEditTap: state.isLoading ? null : pickImage,
);
},
),
),
Gap(16.h),
WnCallout(
key: const Key('signup_privacy_notice'),
title: context.l10n.profilePrivacyTitle,
description: privacyNoticeExpanded.value
? context.l10n.profilePrivacyDescription
: null,
isExpanded: privacyNoticeExpanded.value,
onToggle: () {
privacyNoticeExpanded.value =
!privacyNoticeExpanded.value;
},
),
Gap(16.h),
WnInput(
label: context.l10n.chooseName,
placeholder: context.l10n.enterYourName,
controller: displayNameController,
errorText: state.displayNameError,
onChanged: (_) => clearErrors(),
),
Gap(16.h),
WnInputTextArea(
key: const Key('signup_bio_field'),
label: context.l10n.introduceYourself,
placeholder: context.l10n.writeSomethingAboutYourself,
controller: bioController,
focusNode: bioFocusNode,
textInputAction: TextInputAction.done,
),
],
),
),
),
),
),
),
Gap(10.h),
],
SizedBox(
height: MediaQuery.of(context).viewInsets.bottom.clamp(0.0, 16.h),
),
],
),
),
),
),
],
),
),
if (showCarousel.value)
Positioned.fill(
Positioned.fill(
child: IgnorePointer(
ignoring: !showCarousel.value,
child: FadeTransition(
opacity: fadeAnimation,
child: SafeArea(
@@ -312,6 +347,7 @@ class SignupScreen extends HookConsumerWidget {
),
SizedBox(height: 287.h),
WnSlate(
tag: 'wn-slate-carousel',
child: Padding(
padding: EdgeInsets.symmetric(
vertical: 14.h,
@@ -337,6 +373,7 @@ class SignupScreen extends HookConsumerWidget {
),
),
),
),
],
),
);
@@ -367,26 +404,23 @@ class _LearnMoreButton extends HookWidget {
child: GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: EdgeInsets.symmetric(vertical: 24.h),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.arrow_upward,
key: const Key('learn_more_arrow'),
size: 20.sp,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
WnIcon(
WnIcons.arrowUp,
key: const Key('learn_more_arrow'),
size: 20.sp,
color: colors.backgroundContentPrimary,
),
SizedBox(height: 4.h),
Text(
l10n.learnMore,
style: typography.medium14.copyWith(
color: colors.backgroundContentPrimary,
),
SizedBox(height: 4.h),
Text(
l10n.learnMore,
style: typography.medium14.copyWith(
color: colors.backgroundContentPrimary,
),
),
],
),
),
],
),
),
),
+13 -1
View File
@@ -19,12 +19,16 @@ class WnCallout extends StatelessWidget {
this.description,
this.type = CalloutType.neutral,
this.onDismiss,
this.onToggle,
this.isExpanded = false,
});
final String title;
final String? description;
final CalloutType type;
final VoidCallback? onDismiss;
final VoidCallback? onToggle;
final bool isExpanded;
@override
Widget build(BuildContext context) {
@@ -33,6 +37,7 @@ class WnCallout extends StatelessWidget {
final colorScheme = _getColorScheme(colors);
return Container(
constraints: BoxConstraints(minHeight: 56.h),
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h),
decoration: BoxDecoration(
color: colorScheme.backgroundColor,
@@ -72,6 +77,13 @@ class WnCallout extends StatelessWidget {
icon: WnIcons.closeLarge,
onPressed: onDismiss,
),
if (onToggle != null)
WnIconButton(
key: const Key('callout_toggle'),
icon: isExpanded ? WnIcons.chevronUp : WnIcons.chevronDown,
size: WnIconButtonSize.size36,
onPressed: onToggle,
),
],
),
if (description != null) ...[
@@ -92,7 +104,7 @@ class WnCallout extends StatelessWidget {
switch (type) {
case CalloutType.neutral:
return _CalloutColorScheme(
backgroundColor: colors.fillSecondary,
backgroundColor: colors.backgroundSecondary,
iconColor: colors.backgroundContentPrimary,
titleColor: colors.backgroundContentPrimary,
descriptionColor: colors.backgroundContentSecondary,
+11 -3
View File
@@ -5,7 +5,7 @@ import 'package:whitenoise/widgets/wn_icon.dart';
enum WnIconButtonType { primary, outline, ghost }
enum WnIconButtonSize { size44, size56 }
enum WnIconButtonSize { size36, size44, size56 }
class WnIconButton extends StatelessWidget {
const WnIconButton({
@@ -26,8 +26,16 @@ class WnIconButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = context.colors;
final dimension = size == WnIconButtonSize.size56 ? 56.w : 44.w;
final iconSize = size == WnIconButtonSize.size56 ? 24.w : 18.w;
final dimension = switch (size) {
WnIconButtonSize.size36 => 36.w,
WnIconButtonSize.size44 => 44.w,
WnIconButtonSize.size56 => 56.w,
};
final iconSize = switch (size) {
WnIconButtonSize.size36 => 20.w,
WnIconButtonSize.size44 => 18.w,
WnIconButtonSize.size56 => 24.w,
};
final borderRadius = 8.r;
return SizedBox.square(
+86 -40
View File
@@ -66,14 +66,14 @@ void main() {
}
group('SignupScreen', () {
testWidgets('displays Setup profile title', (tester) async {
testWidgets('displays Set up profile title', (tester) async {
await pumpSignupScreen(tester);
expect(find.text('Setup profile'), findsOneWidget);
expect(find.text('Set up profile'), findsOneWidget);
});
testWidgets('displays name input field', (tester) async {
await pumpSignupScreen(tester);
expect(find.text('Choose a name'), findsOneWidget);
expect(find.text('Name'), findsOneWidget);
});
testWidgets('prefills display name field with generated value', (
@@ -98,32 +98,50 @@ void main() {
expect(nameField.controller?.text, 'Custom Name');
});
testWidgets('clears display name when clear button is tapped', (tester) async {
await pumpSignupScreen(tester);
final nameFieldBefore = tester.widget<TextField>(find.byType(TextField).first);
expect(nameFieldBefore.controller?.text, isNotEmpty);
await tester.tap(find.byKey(const Key('signup_display_name_clear_button')));
await tester.pump();
final nameFieldAfter = tester.widget<TextField>(find.byType(TextField).first);
expect(nameFieldAfter.controller?.text, isEmpty);
});
testWidgets('displays bio input field', (tester) async {
await pumpSignupScreen(tester);
expect(find.text('Introduce yourself'), findsOneWidget);
expect(find.text('About'), findsOneWidget);
});
testWidgets('displays Cancel button', (tester) async {
testWidgets('displays privacy notice in collapsed state', (tester) async {
await pumpSignupScreen(tester);
expect(find.text('Cancel'), findsOneWidget);
expect(find.byKey(const Key('signup_privacy_notice')), findsOneWidget);
expect(find.text('You control what you share'), findsOneWidget);
expect(
find.textContaining('Name, photo, and bio are visible'),
findsNothing,
);
});
testWidgets('displays Sign Up button', (tester) async {
testWidgets('expands privacy notice on toggle tap', (tester) async {
await pumpSignupScreen(tester);
expect(find.text('Sign Up'), findsOneWidget);
await tester.tap(find.byKey(const Key('callout_toggle')));
await tester.pumpAndSettle();
expect(
find.textContaining('Name, photo, and bio are visible'),
findsOneWidget,
);
});
testWidgets('collapses privacy notice on toggle tap', (tester) async {
await pumpSignupScreen(tester);
await tester.tap(find.byKey(const Key('callout_toggle')));
await tester.pumpAndSettle();
expect(
find.textContaining('Name, photo, and bio are visible'),
findsOneWidget,
);
await tester.tap(find.byKey(const Key('callout_toggle')));
await tester.pumpAndSettle();
expect(
find.textContaining('Name, photo, and bio are visible'),
findsNothing,
);
});
testWidgets('displays Create profile button', (tester) async {
await pumpSignupScreen(tester);
expect(find.text('Create profile'), findsOneWidget);
});
group('navigation', () {
@@ -134,13 +152,6 @@ void main() {
expect(find.byType(HomeScreen), findsOneWidget);
});
testWidgets('tapping Cancel returns to home screen', (tester) async {
await pumpSignupScreen(tester);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(find.byType(HomeScreen), findsOneWidget);
});
testWidgets('tapping outside slate returns to home screen', (
tester,
) async {
@@ -163,12 +174,30 @@ void main() {
tester,
) async {
await pumpSignupScreen(tester);
expect(find.byType(WnOnboardingCarousel), findsNothing);
final carousel = find.byType(WnOnboardingCarousel);
expect(
tester
.widget<FadeTransition>(
find.ancestor(of: carousel, matching: find.byType(FadeTransition)).first,
)
.opacity
.value,
0.0,
);
await tester.tap(find.byKey(const Key('learn_more_button')));
await tester.pumpAndSettle();
expect(find.byType(WnOnboardingCarousel), findsOneWidget);
expect(
tester
.widget<FadeTransition>(
find.ancestor(of: carousel, matching: find.byType(FadeTransition)).first,
)
.opacity
.value,
1.0,
);
});
testWidgets('carousel shows Back to sign up button', (tester) async {
@@ -185,13 +214,30 @@ void main() {
await tester.tap(find.byKey(const Key('learn_more_button')));
await tester.pumpAndSettle();
expect(find.byType(WnOnboardingCarousel), findsOneWidget);
final carousel = find.byType(WnOnboardingCarousel);
expect(
tester
.widget<FadeTransition>(
find.ancestor(of: carousel, matching: find.byType(FadeTransition)).first,
)
.opacity
.value,
1.0,
);
await tester.tap(find.byKey(const Key('back_to_signup_button')));
await tester.pumpAndSettle();
expect(find.byType(WnOnboardingCarousel), findsNothing);
expect(find.text('Setup profile'), findsOneWidget);
expect(
tester
.widget<FadeTransition>(
find.ancestor(of: carousel, matching: find.byType(FadeTransition)).first,
)
.opacity
.value,
0.0,
);
expect(find.text('Set up profile'), findsOneWidget);
});
testWidgets('tapping outside does not dismiss when carousel is visible', (
@@ -245,7 +291,7 @@ void main() {
testWidgets('redirects to chat list on success', (tester) async {
await pumpSignupScreen(tester, overrides: overrides);
await tester.enterText(find.byType(TextField).first, 'Test User');
await tester.tap(find.text('Sign Up'));
await tester.tap(find.text('Create profile'));
await tester.pumpAndSettle();
expect(find.byType(ChatListScreen), findsOneWidget);
});
@@ -254,7 +300,7 @@ void main() {
mockAuth.errorToThrow = Exception('Network error');
await pumpSignupScreen(tester, overrides: overrides);
await tester.enterText(find.byType(TextField).first, 'Test User');
await tester.tap(find.text('Sign Up'));
await tester.tap(find.text('Create profile'));
await tester.pumpAndSettle();
expect(find.byType(ChatListScreen), findsNothing);
});
@@ -293,14 +339,14 @@ void main() {
) async {
await pumpSignupWithSmallScreen(tester);
expect(find.text('Choose a name'), findsOneWidget);
expect(find.text('Sign Up'), findsOneWidget);
expect(find.text('Name'), findsOneWidget);
expect(find.text('Create profile'), findsOneWidget);
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
expect(find.text('Choose a name'), findsOneWidget);
expect(find.text('Name'), findsOneWidget);
});
testWidgets('scrolls when bio field is focused and keyboard appears', (
@@ -323,7 +369,7 @@ void main() {
await tester.pumpAndSettle();
expect(controller.offset, greaterThan(initialOffset));
expect(find.text('Choose a name'), findsOneWidget);
expect(find.text('Name'), findsOneWidget);
});
testWidgets('name field remains visible when focused', (
@@ -339,7 +385,7 @@ void main() {
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(find.text('Choose a name'), findsOneWidget);
expect(find.text('Name'), findsOneWidget);
addTearDown(() => tester.view.resetViewInsets());
});
@@ -79,7 +79,7 @@ void main() {
await mountTestApp(tester);
await tester.tap(find.text('Sign Up'));
await tester.pumpAndSettle();
expect(find.text('Choose a name'), findsOneWidget);
expect(find.text('Name'), findsOneWidget);
});
});
}